Versioning
Audience: Plugin maintainers and contributors. End users do not need this. It describes how the Team plugin itself is versioned, and nothing here applies to projects that merely use the plugin.
Team assigns the version at land time, not per PR. A drafted PR carries no
version, no vX.Y.Z title, and no released changelog section. It accumulates
bullets under [Unreleased]. Landing a Team PR is two steps:
- Bump. The dev
version-bumpskill (.claude/skills/version-bump/SKILL.md) is Team’s internal bumper. Run it against currentmain. It assigns the next version, bumps the six version strings, and cuts the[Unreleased]body into a dated## [X.Y.Z]section. It then sets the PR title, runs the land-time consistency assertion, and commitschore(version): X.Y.Z. - Land. The generic, distributed runtime
/shipitskill (skills/shipit/SKILL.md) pushes the branch, waits for CI, and squash-merges.shipitis project-agnostic: it does no versioning or changelog work. Team ships it to users as a general “land a reviewed PR” utility. Team’s own version logic stays inversion-bump.
There is no batch release step. The merge is the release. CI tags and publishes automatically.
Why land time
When every open PR claims its own version up front, parallel PRs contend for the
same number. Every rebase forces a re-bump, and the gate blocks honest work that
has not picked a free slot. Land-time assignment removes the contention
entirely. A PR is a diff plus [Unreleased] bullets until the moment it lands.
version-bump runs only at land, and one PR lands at a time. The assigned
number is thus always free. The serialization is the collision defense.
When “land time” is, exactly
The step immediately before the merge command — not “when the work is
done”. Opening the draft PR, passing review, and going green are all before
land time. A runtime PR therefore sits unbumped for its entire review
lifetime, on purpose, and .github/scripts/version-bump-required.sh exits 1
for all of it. The script is a merge-precondition probe, so that
exit 1 reports an unmet precondition rather than a failure: the
precondition is for merging, which is why the only thing that enforces
it is the pre-merge guard, at the merge attempt.
Bumping earlier is a defect in its own right, independent of the rule.
next-version.sh computes bump(main, level), so a number assigned at
PR-open time is valid only against the main of that moment; the next PR to
land makes it stale, and the pre-merge guard then denies the merge until
someone recomputes it. This is what happened on PR #208, which opened as
v0.36.0 … with a cut changelog section and had to be reverted by hand.
version-bump accordingly fires only on explicit land intent — see its
land-intent precondition — and the
/team pipeline’s PR gate forbids versioning outright.
Only runtime changes bump (the runtime-vs-dev gate)
The version, changelog, and release exist for plugin end users, so a bump is
warranted only when a PR changes the distributed plugin: agents/,
skills/, hooks/, opencode/, or host manifest content — .claude-plugin/,
.codex-plugin/, .agents/plugins/, root plugin.json — (the
Runtime vs. development split). Contributor-facing and
plugin-developer infrastructure (.github/, .claude/, docs/, and build
tooling) never bumps, whatever its conventional-commit
type. A ci:/docs:/test:/chore: PR that ships no runtime change lands
with no bump, no changelog cut, and a plain conventional title (precedent:
710d44c CI, 7d2e218 docs, 0821129 evals feat:).
version-bump runs the check early, in its step 0 and again right after
the bump commit, through .github/scripts/version-bump-required.sh. CI does not enforce the
invariant.
Enforcement is mechanical at the merge attempt: the pre-merge dev hook
(.claude/hooks/pre-merge-guard.mjs) runs the script against the PR’s
remote head and denies a violating gh pr merge on either violation — a
dev-only diff that bumped, or a runtime diff that did not. It reads the script
out of the PR head commit, not from the working tree, because the script is
itself the definition of “runtime file”: a PR that widens that definition has to
be judged by the definition it lands. That read needs a trusted head. A fork
head is unreviewed code, so a fork whose copy differs from the local one is
denied rather than executed. The script measures
“did this branch bump?” against the merge-base, which is the fork point. A
bump-less PR behind a version-bumped main thus reads correctly as “no
bump”. PR title sync uses the same branch-relative measure.
Scope caveat. The guard engages only on a literal
gh pr mergeBash tool call inside a Claude Code session that loaded this repo’s.claude/settings.json. A GitHub-UI merge, a merge run in a raw terminal, a wrapped invocation (bash -c,eval,env,xargs,gh api …/merge, a merge inside a$(…)or backtick substitution body, or any other wrapper command —command,nohup,nice,timeout,stdbuf, … — only thetime,exec, and!prefixes are stripped), a never-expanded spelling (brace expansiongh pr {merge,}, parameter expansionA=merge; gh pr $A, ANSI-C numeric escapes$'\x67h'), a merge inside a shell grouping, conditional, orcaseconstruct (if …; then gh pr merge; fi,{ gh pr merge; },( gh pr merge ),case x in *) gh pr merge;; esac— ordinary shell syntax, no indirection tool needed), or a harness that does not load these hooks all sit outside that reach — and unlike the deleted CI check, this guard raises no red signal on those routes. Nothing else checks the invariant on those routes either: CI does not enforce it (above), so a merge through the UI, a raw terminal, a wrapped call, or another host lands whatever the branch holds. A command the guard cannot parse is not among these routes: it is denied outright whenever its raw text, stripped of the characters bash can splice into a word (quotes, escapes, line continuations,$), containsmerge(fail-closed — bash may already have run an earlier line when a later line fails), and passed through only when the letters cannot be reached without one of the expansions listed above. The guard also evaluates from the repo root while the gated command runs in the session’s working directory, so a selector-lessgh pr mergeissued aftercd-ing to another checkout is judged against this checkout’s PR context — typically a false deny.
Regression #120.
version-bumponce treated every PR as bump-worthy and bumped #118 (a.github/-only CI fix)0.13.1 → 0.13.2, cutting a changelog section. The gate above removes the judgment call.
The bump sequence (version-bump)
Run the dev version-bump skill against current main, on the branch you
intend to land:
- Runtime-vs-dev gate. If the PR changes no runtime files, stop here: no
bump, no changelog cut, plain title. Go straight to
/shipit. Only a runtime change continues to the steps below. - Decides the bump level from what the PR’s runtime change does, not from its commit type (see Choosing the level): observable to a plugin user → minor, internal-only and backward compatible → patch.
- Computes the next free version (
next-version.sh). - Bumps the six version strings.
- Cuts the changelog section: moves the
[Unreleased]body into a dated## [X.Y.Z]section and re-points the footer. - Runs the land-time consistency assertion, after the cut and before the commit (see Land-time consistency assertion).
- Commits the bump (
chore(version): X.Y.Z) and sets the PR title.
Then run /shipit to push, wait for CI, and squash-merge.
Choosing the level
Team is pre-1.0, and that changes which SemVer rules apply.
SemVer 2.0.0 scopes its MAJOR, MINOR, and
PATCH rules (items 8, 7, 6) to x > 0. Team’s version starts 0., so none of
them binds. Item 4 governs: “Major version zero (0.y.z) is for initial
development. Anything MAY change at any time. The public API SHOULD NOT be
considered stable.” The spec assigns no level pre-1.0, so the convention below
is Team’s, written to keep meaning the same thing after 1.0.0.
| Level | When | Reachable pre-1.0 |
|---|---|---|
| minor | A plugin user can observe the difference: a command’s name or arguments, documented behavior, an artifact format, hook behavior, an agent’s model or tools. New and changed capability both. | yes |
| patch | Internal-only and backward compatible — item 6’s “internal change that fixes incorrect behavior”, which needs both qualifiers. | yes |
| major | Never, while the version starts 0.. Item 8 is scoped X > 0, and 1.0.0 “defines the public API” (item 5). |
no |
Two consequences worth stating, because both were previously decided wrong:
- A breaking change pre-1.0 is a minor, not a major. Bumping to 1.0.0 to describe one broken interface commits the whole plugin to API stability. Declaring 1.0.0 is a deliberate decision, never a side effect of a bump.
- The commit type is not the input. It describes intent, not blast radius. A
fix:that changes observable behavior is a minor; afeat:confined to internals is a patch. PR #228 is the worked example: afix:that removed the/shipit--yesargument and its confirmation prompt landed as 0.44.0.
The full decision procedure, with the spec quoted verbatim, is step 1 of
.claude/skills/version-bump/SKILL.md.
Land-time consistency assertion
This is the in-tree replacement for the per-PR CI gate that used to enforce a
bump on every PR. The version is assigned only at land time. The
released-changelog invariants thus hold only after version-bump cuts the
section. version-bump re-runs them itself, and CI does not check them on every
push. The assertion runs after the changelog cut and before the commit. It
fails fast and loud, and it never commits an invalid tree. It checks that:
.claude/scripts/check-version-consistency.shpasses (strict semver, the six strings agree, and the host manifests agree on names and description).- The dated
## [X.Y.Z] - YYYY-MM-DDreleased section exists. - The footer carries a
[X.Y.Z]: …compare/…link. The[Unreleased]footer compares fromvX.Y.Z...HEAD.
If any check fails, version-bump stops before committing. Nothing is
committed, pushed, or merged. See .claude/skills/version-bump/SKILL.md step 5.
The six version strings
The version lives in six places across five files. Forgetting one ships an internally inconsistent tree, and this is the single most common versioning mistake in this repo’s history.
| File | Occurrences |
|---|---|
.claude-plugin/plugin.json |
1 (version): canonical. CI reads this one. |
.claude-plugin/marketplace.json |
2 (metadata.version and plugins[0].version) |
.codex-plugin/plugin.json |
1 (version): what Codex reports |
package.json |
1 (version) |
plugin.json (repo root) |
1 (version): what Antigravity reports |
Each host reads its own manifest. Codex prefers .codex-plugin/plugin.json
over the Claude one, so a stale version there makes one release look like two
different versions depending on which host a user installed from. Antigravity
reads the root plugin.json; that one cannot move into a directory of its own,
because the host resolves skills/ and agents/ as siblings of the manifest.
One grep proves consistency:
grep -rn '"version"' package.json plugin.json .claude-plugin/plugin.json \
.claude-plugin/marketplace.json .codex-plugin/plugin.json
All six lines must show the same version.
.claude/scripts/check-version-consistency.sh asserts this mechanically.
Picking the next version
version-bump computes the version with the helper:
.claude/scripts/next-version.sh <major|minor|patch>
It prints bump(<default branch>'s version, level). This is a deterministic
pure function of the base version and the level, with no open-PR scan. The
script reads the base from the remote’s default branch and resolves it through
origin/HEAD. main, master, or any other default branch works, because the
name is not hardcoded.
Under the land-time model the version is assigned against current main, and
landing is serialized to one PR at a time. bump(main, level) is thus always
free. Three defenses cover a collision: serialization, shipit’s
rebase-and-recompute on a concurrent race, and release-on-merge.yml’s
duplicate-tag rejection. Set BASE_VERSION=x.y.z to override the base the
script reads. Its tests use this override.
Earlier revisions walked past any version claimed by another open PR, through the GitHub API. That was the retired per-PR model’s mechanism. It made the output depend on whatever PRs were open, and it skipped free versions that a stale PR happened to claim. It is gone. Do not reintroduce it.
Changelog: accumulate under [Unreleased], cut at land time
[Unreleased] accumulates bullets while the PR is in flight. The dated section
is cut only when the PR lands.
- While drafting (
team-pr): add this PR’s user-facing bullets under## [Unreleased](entry style perskills/changelog/SKILL.md). No dated section and no footer compare link, because the PR carries no version yet. - At land time (
version-bump): move the[Unreleased]body into a new dated section, leaving[Unreleased]empty again:- Insert
## [X.Y.Z] - YYYY-MM-DD(today’s date) directly below## [Unreleased], containing the accumulated body. - Update the link-reference footer:
[Unreleased]compare base →vX.Y.Z...HEAD- Add
[X.Y.Z]: https://github.com/bostonaholic/team/compare/v<prev>...vX.Y.Z
- Insert
The dated section becomes the GitHub release notes verbatim (see below). Write
it for a reader who must decide if they upgrade.
Empty [Unreleased] at land: version-bump derives at least one bullet
from the PR’s commits. If it cannot, it stops and asks for an entry. It never
cuts an empty section, because release-on-merge.yml errors on empty release
notes.
PR title
A drafted PR uses a plain conventional title, <type>: <subject>, with no
version prefix. version-bump sets the vX.Y.Z <type>: <subject> title when it
bumps at land time:
vX.Y.Z <type>: <subject>
An example is v0.6.0 feat: add the shipit land skill. The PR title sync
workflow rewrites a drifted title only when the branch bumped the version
forward of its fork point. It reads the version at the PR head and compares it
against the merge-base, not the live base tip. A bump-less PR thus no-ops, no
matter how far main advanced after the branch forked (#104). The workflow is a
backstop, not the mechanism.
What CI enforces, and where
Every check lives at the cheapest layer that can catch it:
| Check | Layer | Where |
|---|---|---|
| Runtime-vs-dev bump invariant. A runtime diff must bump. A dev-only diff must not. The measure is relative to the fork point. | Pre-merge dev hook | .github/scripts/version-bump-required.sh, .claude/hooks/pre-merge-guard.mjs |
| Six version strings agree, on strict semver, and the host manifests agree on the plugin and marketplace names. This holds on every commit, drafted or landed. | Land-time assertion (version-bump) |
.claude/scripts/check-version-consistency.sh |
| Released-section and footer-compare-link invariants hold for the assigned version. It runs after the changelog cut and before the commit. | Land-time assertion (version-bump) |
.claude/skills/version-bump/SKILL.md |
Title prefix matches the version. It applies only when the branch bumped the version forward of its fork point, after version-bump bumps. It no-ops otherwise. |
CI (needs PR context) | .github/workflows/pr-title-sync.yml |
| Tag + GitHub release on merge | CI (needs write perms) | .github/workflows/release-on-merge.yml |
The land-time assertion row is the in-tree replacement for the per-PR CI gate that used to enforce a bump on every PR. See Land-time consistency assertion for what it checks and when.
The bump-invariant row similarly replaced a per-PR CI check. Because the
version is assigned only at land time, that check was structurally red for a
runtime PR’s whole review lifetime, so it was retired with no replacement
workflow: the pre-merge dev hook (.claude/hooks/pre-merge-guard.mjs) now
denies a violating merge at the merge attempt, and version-bump runs the
script early, while recovery is still local.
Release on merge
On every push to main, release-on-merge.yml:
- Reads the version from
.claude-plugin/plugin.json. - No-ops if the GitHub release
vX.Y.Zalready exists (idempotent, so it is safe to re-run after a partial failure). - Extracts that version’s
## [X.Y.Z]section fromCHANGELOG.mdas the release notes (verbatim: the changelog section is the release notes). - Creates the annotated tag
vX.Y.Z(messageRelease vX.Y.Z) if missing, pushes it, and publishes the GitHub release.
Because version-bump cut the section before landing, the section the release
workflow reads is exactly what version-bump wrote.
Recovery
/shipit stopped before merge (CI failed or timed out)
The chore(version) bump commit is already on the branch (committed by
version-bump, pushed by /shipit). Only the merge did not happen. Fix CI
(push the fix to the same branch), then re-run /shipit: it pushes any
new commits, waits again, and merges. Do not re-run version-bump. The
version was already assigned, and a second bump would create a redundant commit.
The pre-merge guard denied the merge (stale bump after a rebase)
/shipit’s step 5 rebases a behind-base branch, and the rebase moves the fork
point — a valid bump can go stale (the branch bumped 0.13.1 → 0.13.2 while
main advanced to 0.14.0). At the merge attempt the guard re-runs the
invariant against the rebased head and denies. Re-run version-bump when,
and only when, the guard denies a bump that a rebase left stale — that
condition is what “Do not re-run version-bump” above leaves open: drop the
chore(version) commit, undo the changelog cut, re-run version-bump from
step 0 (it recomputes against the new base), re-title, and re-run /shipit.
A denial loop is reachable here, and it is expected, not a bug: /shipit
step 5 rebases and re-runs the 30-minute CI wait, and if main advances
during that wait, the guard’s up-to-date precondition denies again. Landing is
serialized to one PR at a time, so the loop is rare in practice.
The pre-merge guard denied the merge (missing or wrongful bump)
The plain denial: a runtime PR that never bumped, or a dev-only PR still
carrying a stray bump. Run version-bump normally — its step 0 re-runs the
same script and says which case this is: continue into the bump steps for a
missing bump, or drop the chore(version) commit and undo the changelog cut
for a wrongful one — and in the wrongful case also re-title: a re-entry that
ends at “no bump” must strip the stale vX.Y.Z prefix back to the plain
conventional title itself, because the title backstop never strips a stale
prefix (version-bump’s step 8 names this). Then re-run /shipit.
The pre-merge guard denied the merge (no verdict available)
Two denials say the guard could not render a verdict at all, so neither is about the bump:
- The head carries no
.github/scripts/version-bump-required.sh. Restore it on the branch (git checkout origin/<default> -- <path>), push, and re-run/shipit. - A fork head’s copy of that script differs from the local one. The guard
will not execute a gate script it has not reviewed. Read the head’s copy
(
git show <head>:<path>), and once you trust it, land the PR deliberately outside the guard (the scope caveat above lists the routes).
A version string was missed and the tag is already pushed
git add the fix, git commit --amend --no-edit, re-point the tag with
git tag -f -a vX.Y.Z -m "Release vX.Y.Z", then
git push --force-with-lease origin main && git push --force origin vX.Y.Z.
This is safe only if no commits landed after the broken one. First make sure
that origin/main still equals your pre-amend commit. This case is
near-impossible. version-bump’s land-time assertion
checks string agreement before the merge.
The release workflow failed after merge
Re-run the failed Release on merge workflow run. It is idempotent (keyed on
release existence first, then tag existence). For a fully manual fallback:
V=$(jq -r .version .claude-plugin/plugin.json)
awk "/^## \[$V\]/{f=1;next} /^## \[/{f=0} f" CHANGELOG.md > /tmp/notes.md
git tag -a "v$V" -m "Release v$V" && git push origin "v$V"
gh release create "v$V" --title "v$V" --notes-file /tmp/notes.md
Read next
- Project Tracking: the board the PR’s issue moves across.