Versions and branch protection: what a version of edakit is, how main is protected, how a concept still gets there¶
A source
This page is a source of edakit – a concept, a prompt or the rules of a session –, shown as it is in the repository. Read it as such; the guides say what edakit does.
Status 2026-11-01: decided and built; moved to docs/concepts/done/ the same day – what is left is in TODO.md: TD-48
(branches left lying around), TD-49 (1.0.0 – released on 2026-11-02 at the first order, #210), TD-50 (the key edakit in edakit.toml, later) and TD-79
(merge.py and generated files). Decided. The owner took the four decisions the same day in a decision round in Claude
Code's form (the table at the end): 0.2.0 first, with 1.0 once the documentation's entry layer is built; the notes lane;
every merge a version of its own; the projects on the newest version, unpinned. The owner then added three points: a
changelog from now on; the major number is the owner's to release, minor and patch are Claude's; a new rule is a patch,
and the rule for the rest kept simple. What Claude decided itself the owner confirmed the same day. Each step starts on
the owner's word, listed in TODO.md. V.1 and V.2 (TD-45) built on the owner's word the same day, on the branch
versioning-v1; building them made three things simpler, written in below: the check changes is part of version,
the paths decide which merge is no version (no fragment none), and the working rule for merging went into CLAUDE.md
with V.2 rather than V.4, so that no merge comes past merge.py while the rulesets wait. On the owner's word the
reader's documentation came with V.2 too: README (Install, Versions), handbook 2.3, 10.5 (--version), 14.4 and the
glossary's "Version". Written against main at d633a4b. Asked for by the owner: edakit has reached the point where it
should work with version numbers and branch protection from now on – a concept for both, and, since a concept could then
no longer be committed straight to main, an elegant way for concepts to get there all the same. V.3 (TD-46) built the
same day: .github/notes.py, which merges by itself instead of GitHub's auto-merge. V.4 (TD-47) built the same day: the
rulesets and the settings as files with .github/rulesets.py, merge.py no longer numbering again a pull request
without a version; the line in the four repository skills that writes a fragment per change, one per proposal, follows
in a pull request of its own once the batch of proposals being triaged is through.
The short answer¶
Every merge that changes edakit is a version of its own, and its number tells a project whether it has to act. The owner sets the major number when they release one, 1.0 once the documentation's entry layer is built. Claude sets the other two by one question: must a project change its files or sign again when it updates? If so, the minor number goes up, and the changelog says what to do. Otherwise the patch number goes up, for a new rule, a new feature or a fix.
The number is written in one place, and the merge sets it. One small file per branch says what kind of change the branch makes, and those files give both the number and the changelog. So parallel sessions never pick a number by hand and never conflict over the changelog. The marketplace entry points at the newest version's tag. A project installs that version, and the owner's projects follow it unpinned, as they follow main today.
main gets rulesets that are kept as JSON in the repository. Every change comes through a pull request with green
checks, and merge.py merges only while main still has the number it numbered from, so two merges never get the
same number. Nobody may
force-push or delete main, and the owner has no bypass. One fact shapes all of it: every session acts as the owner's
GitHub account. GitHub cannot tell a session from the owner, so the protection guards against slips and against the
Actions. It does not lock out the sessions, and the owner's OK for code stays a working rule.
Concepts and the todo list keep their speed through the notes lane. A pull request from a branch notes/<topic>
that changes only docs/concepts/ and TODO.md is merged as soon as its checks are green, with no OK needed, as today,
and it is no version. A required check makes sure nothing else slips through that lane. One command lands a session's
edits there from any worktree and any branch, with the checkout left alone. The concept is on main about five minutes
later.
What is there today¶
- No version.
pyproject.tomlandedakit/__init__.pyhave said 0.1.0 since the first commit. There are no tags and no changelog..claude-plugin/plugin.jsonhas noversion, so Claude Code takes the commit as the version. In~/.claude/plugins/installed_plugins.jsonthis machine, the bell and pytree all run8c6652cd52bd, a merge from this morning. That state lives in one machine's cache and nowhere in a repository. - Manifests that say nothing. Every gate run writes
tools.edakit: "0.1.0"(edakit/release/gate.py,header). So no released board can say which edakit released it. - pytree pins in two halves. Its CI runs the command line at a commit (
hardware/edakit.sh, its D-50), while its plugin, skills and agents follow main. The skills come from one state of edakit and the CLI from another. - main is open. There is no branch protection and no ruleset. Of the 94 merges, most were a local
git mergepushed to main, and pull requests come only from the proposals Action. That Action runs withcontents: write, so only its prompt keeps it off main. - Concepts and TODO.md go straight to main. CLAUDE.md wants a concept on main because the owner follows the repository on GitHub. The owner's rule for todos wants an item ticked right away by whichever session learns it.
- CI on every push and pull request: Tests (
testabout 5 min,kicadabout 1 min) and Pages (build15 s, which also fails on a broken link). - Several sessions at once, all with the owner's
ghlogin and git credentials. - The plugin cache holds no
.git.~/.claude/plugins/cache/edakit/edakit/<version>/holds the files and a.venv. So a number derived from the tag when the code runs would be missing there, and the number has to be written in the tagged commit itself.
What a version of edakit is¶
What a project relies on¶
These are the parts of edakit a project's files depend on:
- the keys of
edakit.toml; - the formats of the graph, the parts entries, the requirements file, the sign-offs and
scripts.lock; - the commands and their exit codes;
- the codes of the catalog and their severities;
- the hashes the sign-offs are bound to;
- the seal of a proposal;
- the skills and agents.
The numbers¶
Three numbers, major.minor.patch, starting at 0.2.0:
| Number | Set by | When | Examples |
|---|---|---|---|
| Major | the owner | when the owner releases one; Claude may propose it, never set it | 1.0.0 once step D.2 of documentation.md, the entry layer (start page, quickstart, how it works), is built |
| Minor | Claude | a project must change one of its files or sign again when it updates | a key, file format, command or exit code removed or changed in meaning; a hash computed differently (sign-offs void); a retired code that waivers name; a new seal format |
| Patch | Claude | everything else | a new rule (check, hint, block, parts fact), a severity raised; a new command, key, maker or skill; a fix; the handbook; a prompt's wording |
| – | the paths | a merge that changes nothing a project reads, the notes lane among them: no version, no fragment | tests/, .github/, evals/, docs/, examples/, upstream/, .claude/, TODO.md, CLAUDE.md, README.md |
So a single question settles the number: does a project have to do something when it updates? The rule stays the
same before and after 1.0. A minor version brings a way across with it: edakit convert where a format changes, and
in every case lines in the changelog that say what a project does, such as which sign-offs to renew. The
implementation plan already names every hash a step changes. Each such step is a minor version.
The owner releases a major version by naming the pull request: merge.py <n> --major.
Where the number is written¶
- One place. The text
__version__ = "0.2.0"inedakit/__init__.py.pyproject.tomlreads it from there (hatch:dynamic = ["version"],[tool.hatch.version] path), so even a checkout that nothing has installed can be read, andpyproject.tomlstays untouched by a version. - The marketplace entry carries
versionand the source{"source": "github", "repo": "tgd1975/edakit", "ref": "v0.2.0"}. The plugin a project installs is exactly the tagged commit.plugin.jsonstays without a version, so that the number is not written twice. - A test holds them together: the entry's version equals the package's, the ref is
vplus that number, and the top section of the changelog has that number. edakit --versionprints0.2.0. From a git checkout away from the tag it prints0.2.0+g1a2b3c4. The gate manifest'stools.edakittakes the same string, so a manifest names the edakit that released the board, down to the commit if it was not a version.
How a project gets a version¶
Claude Code keys its plugin cache by version. A plugin is only updated when the version changes, and a running session
keeps the old one until /reload-plugins or a new session starts (code.claude.com, plugins: "Version management",
"Host and maintain").
- The newest version, the default and the owner's projects.
/plugin marketplace add tgd1975/edakit, as today. The marketplace comes from main, and its entry points at the newest tag. Updating is the same command as today (README, Install), and auto-update is off for marketplaces outside Anthropic. Since every merge that changes edakit is a version, this is what main is today, now with a number. - Pinned, for whoever wants it. A project sets
"ref": "v0.4.2"in the github source ofextraKnownMarketplaces.edakitin its.claude/settings.json. Its CI runsuvx --from git+https://github.com/tgd1975/edakit@v0.4.2 edakit. Skills and command line then share one version, written in its own repository. The README says how to do it. The owner's projects do not use it.
A second channel for main is not needed. main holds nothing that is not already a version, except concepts and the todo list. pytree's CI pin to a commit (its D-50) is pytree's own decision. With tags it could name a version instead, and that is for its session to decide, in its repository.
The changelog¶
CHANGELOG.md sits at the root, newest version first: a heading with the number and the date, then the lines of
that merge. A minor version's lines begin with When updating: and what a project does. It is written for a maker
who updates, not for whoever wrote the code.
It starts now. Its first section, 0.2.0, lists what was merged from 2026-11-01 on, taken from the merge messages, which already describe each change. Everything before that is 0.1.0, kept in the git history.
Its lines come from fragments. Each branch writes one file of its own, changes/<branch>.md. The first line is
minor or patch, and the lines after it are the text for the changelog (changes/README.md). The merge turns the
fragments into the version's section and deletes them, so changes/ on main holds nothing but its README. While
several sessions work at once, every branch writes into a file of its own and none waits for another. A branch that
changes nothing a project reads needs none: the list of those paths is NO_VERSION in .github/versions.py, and such
a merge is no version, a merge through the notes lane among them.
The required check version (below) fails a pull request that changes what a project reads but carries no fragment.
Every proposal that triage accepts adds its own fragment
(changes/rule-<issue>.md, changes/feature-<issue>.md), a patch unless a project has to act, which is one line
more in the triage skills.
A merge is a version¶
edakit's own word "release" already means a board's release (handbook 8, edakit release). So this concept speaks of
"a version of edakit" and of "edakit's GitHub release", and the handbook keeps "release" for the board.
The number has to be in the pull request before it is merged. A workflow cannot write it afterwards: main takes
nothing but a merged pull request, a commit by the GITHUB_TOKEN would start no checks, and a second identity is not
proposed (below). So with the owner's OK, a session merges through .github/merge.py <n>:
- It brings the branch onto the newest main. A conflict outside the version's own lines stops it and names the files.
- It works out the next number from main's number and the branch's fragments: minor if one says
minor, otherwise patch. - It writes that number in
edakit/__init__.pyand in the marketplace entry (version and ref), turns the fragments into the changelog's new section and deletes them, and commits that as "edakit 0.2.6". - It pushes the branch, waits for the checks (about 5 minutes) and merges with
--match-head-commit, so that nothing pushed meanwhile slips in. - It merges only while main still has the number it numbered from. A push of
TODO.mdor a concept in the meantime changes nothing. A version merged in the meantime does: then the script goes back to step 1, and since it rewrites the version's lines anyway, they never conflict.
The workflow release.yml runs on every push to main. When the package's number has no tag yet, it sets the tag
v0.2.6 on that merge commit and opens the GitHub release with the changelog section as its text. A merge without a
version (nothing a project reads) leaves the number alone, and the workflow does nothing. A branch that the proposals
workflow pushes with its own token starts no checks (proposals-pipeline.md gives it that
token). merge.py's push of the numbered commit comes from the owner's account and does start them, so a proposals pull
request gets its checks at the merge.
A required check version guards the button. A pull request that carries fragments is not numbered yet, and the check
stays red with the text "set when merging: .github/merge.py <n>". So GitHub's merge button cannot merge a change
without its number. The owner says "merge #102" to a session, as today. A pull request that merge.py has numbered
passes the check, provided its number follows main's.
Merges now run one after another, about five minutes each, and merge.py runs in the background with a monitor that
reports each step. There are no hotfix branches: a fix is a pull request like any other and a version minutes later.
Branch protection¶
The fact that shapes it¶
Every session pushes and merges as the owner's account. Any right the owner keeps, every session has too, and any bypass the owner takes is a bypass for all of them. So the rules below guard against four things:
- a slip, such as a commit on local main or a push of a red branch;
- a force push;
- a change merged without its number;
- the Actions: the proposals pipeline runs Claude with the Claude App and the GITHUB_TOKEN, both with
contents: write.
The owner's OK for code stays a working rule in CLAUDE.md. A real lock would take a second identity for the sessions, a GitHub App or a machine account, plus a required approval from the owner on every pull request. That is not proposed now: it costs an identity handed to every session and a click on GitHub for each merge, against a risk the working rule has handled so far. The option stays open.
The rules, as text¶
The files sit in .github/rulesets/ – one per ruleset, settings.json for the merge settings and actions/ for the
Actions policies (below, "The check paths"). They are applied with python3 .github/rulesets.py apply, and without
apply the script shows what differs between the files and GitHub. The protection is then text in the repository like
everything else. A change to it goes through a pull request and is applied after the merge, on the owner's word.
| Ruleset | Target | Rules | Bypass |
|---|---|---|---|
main |
main | a pull request, no approval required (the owner cannot approve their own, and the sessions are the owner); the checks test, kicad, build, paths and version green, not strict; no force push; no deletion |
nobody, the owner included |
~~main-updates~~ |
main | only the bypass may update the branch – dropped on 2026-11-01, see below | the role Repository admin, only through a pull request |
tags |
v* |
no deletion, no update, no force push; creating stays open for the release workflow | nobody: a version once out is never moved |
The repository's settings also delete head branches after a merge and offer only merge commits.
Squash and rebase merges are off, because the merge message carries the description, as the history shows
(merge.py takes it as --subject "Merge branch 'cpl-turn': …").
- Not strict. A pull request need not sit on the newest main. Two branches merged within the same minutes must
not both carry 0.2.6, since git merges two identical changes to one line without a conflict.
merge.pyprevents that: it merges only while main still has the number it numbered from (step 5 above). A strict rule would do the same at a high price. Every push ofTODO.md, which comes many times an hour, would send each waiting pull request through its checks again. This was found while building TD-45, and it replaces the strict rule the owner confirmed. - Skipped counts as passed.
pathsandversionrun only where they apply. GitHub counts a job skipped by itsifas successful, so a required check never blocks a pull request it does not concern. main-updatescloses the gap that proposals-pipeline.md found (its layer 1): today only the prompt keeps the Action from changing main. Whether GitHub accepts the admin role as a bypass on a personal repository is checked when the rules are applied. If it does not, this ruleset is left out and the working rule stays as it is. Dropped on the owner's word on 2026-11-01, an hour after it was applied. GitHub took the role, but it applies a bypass only when the merge asks for it (gh pr merge --admin), and Claude Code's auto mode rightly refuses that as a merge without review, so no session could merge at all. Claude had not checked how a bypass is taken before proposing it. Its goal, that no Action ever merges into main, is held by the proposals pipeline itself (proposals-pipeline.md): Claude runs there without a token that writes, its fixed jobs never merge, andpathskeeps the pipeline from being changed through itself. The rules that differ by branch family belong in the checks, which run from main: CI can hold a pull request to what it may change, but it cannot tell who merges it.- In an emergency the owner switches a ruleset off in the settings and on again afterwards. That is one click, and it shows in the ruleset's history.
The check paths¶
The check holds each family of branches to its paths:
| Branch | May change | Must never change |
|---|---|---|
notes/* |
docs/concepts/**, TODO.md |
anything else |
rules/proposals, features/proposals |
the rest | .github/, .claude/, CLAUDE.md, edakit/proposal.py, tests/test_proposal.py, skills/propose-rule/, skills/propose-feature/, pyproject.toml, uv.lock: the pipeline is never changed through itself |
| any other | everything | – |
The version's own lines (edakit/__init__.py, .claude-plugin/marketplace.json, CHANGELOG.md, changes/) lie
outside the proposals' list, so merge.py can number a proposals pull request too. The proposals' list comes from
proposals-pipeline.md, section "The pipeline's paths". It lives once, in
edakit/proposal.py with a test. The lane's paths are LANE in .github/versions.py.
A branch must not be able to loosen its own check. A workflow on pull_request runs the head's version of its file,
so a branch that edits the check would pass it. So paths and version run on pull_request_target, from main's
workflow file and main's lists, and read nothing from the pull request but its changed files and, for version, the
number and the changelog's top section. They never check out or run the head's code. The jobs write their result as
a commit status named paths and version onto the pull request's head (statuses: write), so the required check
sits where GitHub and merge.py look for it, whatever commit a run on pull_request_target is reported on
(.github/workflows/pull-request.yml).
From 2026-12-02 GitHub blocks pull_request_target in a public repository that has no Actions event policy for it –
a brownout on 2026-11-09 held #361 for an hour. On the owner's decision of the same day the policy is text too:
.github/rulesets/actions/pull-request-target.json allows the event for pull-request.yml alone, a test holds it to
the workflows that use it, and rulesets.py compares and applies it with the rulesets.
What changes for a session¶
- Nobody commits on main. The main clone only follows main with
git pull --ff-only. A push to main is refused with "Repository rule violations", and the commit moves to a branch. - Code goes from a branch with its fragment to a pull request (
gh pr create). With the owner's OK the session runs.github/merge.py <n>. The notice to the other sessions comes before the merge, as today. - A concept or a todo goes through the notes lane.
Concepts and the todo list: the notes lane¶
With a pull request required, every concept and every ticked todo would wait for the owner to merge it. The owner would end up merging the tick of TD-12.
| Way | Why not |
|---|---|
| A bypass for the owner | every session has it, so the protection is gone for everything |
| Concepts on a branch | not "in the repository" for the owner (CLAUDE.md) |
| The wiki or Discussions | not in the repository, not on the site |
| An exception by path on main | GitHub's rules for a branch know none |
| An Action that merges notes itself | a merge by the GITHUB_TOKEN starts no workflow, so the site would not be published |
The notes lane:
- A pull request from
notes/<topic>that changes onlydocs/concepts/**andTODO.mdis merged bynotes.pyas soon as its checks are green. No OK is needed, as today. It needs no fragment and is no version. - The required check
paths(above) fails anotes/pull request that touches anything else, so no code reaches main through the lane, not even a change to the check itself. notes.pymerges from the owner's account, so the push to main starts Pages, as today. GitHub's auto-merge is not used: it needs a setting, and before the rulesets it would merge before the checks ran (changed while building TD-46).- A concept is on main about five minutes after the push, which is the time
testtakes, and on the site after that.
One command, .github/notes.py "<message>" <files…>, from any worktree on any branch:
- It checks that every file named lies in the lane.
- It takes the session's own edits of those files: the diff against the commit its checkout stands on, not the
files themselves. A
TODO.mdtaken whole from an older checkout would undo the ticks another session has landed in the meantime. - It applies those edits with a three-way merge onto the newest main, in a temporary index. The checkout, its branch and its other changes stay as they are. If two sessions changed the same lines, it stops and names them. The session pulls and runs it again.
- It pushes the commit to
notes/<topic>, opens the pull request, waits for the checks and merges it. If main has moved into a conflict in the meantime, it starts again from step 2 on the newest main. It ends with the merge or with a conflict it names. A session runs it in the background. - It puts the files in the checkout back to its commit, unless they were edited again in the meantime. They come back with main: the main clone on main is fast-forwarded by the script, and a code branch gets them with its next rebase. Git refuses to fast-forward over a working tree that holds the same edit uncommitted (tried on 2026-11-01). The edits left in place would block the main clone's next pull.
Moving a concept from new/ to done/ changes links in the handbook or the README. That lies outside the lane and
becomes an ordinary pull request with the owner's OK, which fits, since moving a concept means it is settled. The lane
covers exactly these two paths and is widened only on the owner's word. upstream/README.md, for one, changes
together with code.
Against the yardstick¶
Nothing is left only in EasyEDA or only in someone's head. Things that today exist only in one machine's plugin cache become text:
- the version:
edakit/__init__.py, the marketplace entry; - the protection:
.github/rulesets/; - the history of what changed:
CHANGELOG.mdfromchanges/; - which edakit released a board: its manifest;
- for a project that pins, which edakit it runs: its own settings and CI script.
Steps¶
| Step | What | Files | Size | Waits for |
|---|---|---|---|---|
| V.1 | The number in one place (0.2.0); edakit --version; the manifest's tools.edakit with the commit; the marketplace entry with version and ref; plugin.json without; the test that holds them together |
edakit/__init__.py, pyproject.toml, edakit/cli.py, edakit/release/gate.py, .claude-plugin/, tests/test_version.py |
S | – |
| V.2 | Changelog, fragments, merge: CHANGELOG.md, changes/README.md (the rule for the number), .github/merge.py, .github/versions.py, release.yml; the checks paths and version (on pull_request_target, their lists read from main); the working rule in CLAUDE.md – a fragment per branch, merging through merge.py; the reader's documentation: README (Install, Versions), handbook 2.3 (update, pin, --version), 10.5, 14.4, the glossary. On one branch with V.1: its merge is 0.2.0, the first version, tagged by release.yml |
CHANGELOG.md, changes/, .github/, edakit/proposal.py (the list, with the proposals concept) |
M | V.1 |
| V.3 | The notes lane: .github/notes.py, paths for notes/* |
.github/ |
M | V.2 |
| V.4 | The rules as text, applied with gh api on the owner's word once V.2 and V.3 are on main (the required checks must exist), with the working rules that go with them: CLAUDE.md (the notes lane, the protection), docs/organisation.md (changes/, CHANGELOG.md, .github/rulesets/), the triage, add-rule and add-feature skills (a fragment per change) |
.github/rulesets/, CLAUDE.md, docs/organisation.md, .claude/skills/ |
M | V.3 |
| V.5 | Branches left lying around: 9 claude/* and 2 ccr-* on origin, the local ones and worktrees – listed, then merged or deleted on the owner's word; other sessions' branches named with their session |
– | S | – |
| V.6 | 1.0.0: on the owner's word, once D.2 of documentation.md is built (merge.py --major) – released on 2026-11-02 at the first order, #210 |
– | S | D.2 |
| V.7 | Later: edakit = ">=0.4" under [project] in edakit.toml. edakit check notes when the running edakit does not meet it, and the SessionStart hook says which edakit runs, so a project that needs a newer edakit says so |
edakit/config.py, edakit/agreements.py, handbook 10 |
M | V.2, the owner's word |
Not proposed¶
- A merge queue: it would serialise the merges on GitHub's side, but GitHub offers it only to repositories owned
by an organisation.
merge.pydoes the same work for this repository. - Signed commits: they would take a key in every session and in the Action, and protect against nothing that threatens this repository.
- Conventional commits with release-please: the commit messages here are prose that describes what changed, and the fragments carry the kind instead.
claude plugin tag: its tagsedakit--v1.0.0serve plugins that depend on edakit, and there are none.- A channel for main: main holds nothing that is not already a version.
Decisions¶
| # | Question | Recommended | Alternatives | Decision |
|---|---|---|---|---|
| 1 | The scheme and the first number | Semantic versioning from 1.0.0: three numbers, each with a meaning for a project | 0.2.0, and 1.0 once the documentation's entry layer is done; CalVer (2026.11.0) – says when, not what to do | 0.2.0, 1.0 after D.2 (the owner, 2026-11-01). Added by the owner the same day: the major number is the owner's to release; minor and patch are Claude's; a new rule is a patch; the rest kept simple – minor when a project must act, patch otherwise |
| 2 | How concepts and TODO.md reach main | the notes lane, auto-merge once green | a pull request with the owner's OK, like code; a bypass for the owner, which is one for every session | the notes lane (the owner, 2026-11-01) |
| 3 | When a version is made | on the owner's word, several merges in one | every merge into main a version of its own; once a week | every merge (the owner, 2026-11-01): numbered by merge.py before the merge, which merges only while main still has the number it numbered from; a red version check before that |
| 4 | The owner's projects | each pins a version in its own repository, and a main channel for live runs | the newest version, unpinned; the main channel throughout | the newest version, unpinned (the owner, 2026-11-01): no main channel, pinning in the README for whoever wants it, pytree's D-50 its own |
What Claude decided itself, confirmed by the owner on 2026-11-01:
- no approval required;
- the checks strict (decision 3 makes it necessary) – replaced while building TD-45: not strict,
merge.pyholds the numbers apart ("Not strict" above); - merge commits only;
- no bypass on
main; - the ruleset
main-updates– dropped on the owner's word the same day ("main-updates" above); - tags never moved;
- head branches deleted after a merge;
- one question for minor or patch: a new command, key, maker or skill is a patch like a rule, since a project need not act;
- the changelog's first section collects the merges from 2026-11-01 on, from their merge messages;
- fragments, deleted by the merge;
- a merge that changes nothing a project reads, the notes lane among them, is no version – decided by its paths;
- the red check
versionuntilmerge.pynumbers the pull request, so the button cannot merge an unnumbered change; - the number in
edakit/__init__.py,plugin.jsonwithout a version, the marketplace entry at the tag; - no channel for main;
- the lane only for
docs/concepts/andTODO.md; - no second identity for now.