team

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:

  1. Bump. The dev version-bump skill (.claude/skills/version-bump/SKILL.md) is Team’s internal bumper. Run it against current main. 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 commits chore(version): X.Y.Z.
  2. Land. The generic, distributed runtime /shipit skill (skills/shipit/SKILL.md) pushes the branch, waits for CI, and squash-merges. shipit is 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 in version-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 merge Bash 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 the time, exec, and ! prefixes are stripped), a never-expanded spelling (brace expansion gh pr {merge,}, parameter expansion A=merge; gh pr $A, ANSI-C numeric escapes $'\x67h'), a merge inside a shell grouping, conditional, or case construct (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, $), contains merge (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-less gh pr merge issued after cd-ing to another checkout is judged against this checkout’s PR context — typically a false deny.

Regression #120. version-bump once 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:

  1. 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.
  2. 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.
  3. Computes the next free version (next-version.sh).
  4. Bumps the six version strings.
  5. Cuts the changelog section: moves the [Unreleased] body into a dated ## [X.Y.Z] section and re-points the footer.
  6. Runs the land-time consistency assertion, after the cut and before the commit (see Land-time consistency assertion).
  7. 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:

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:

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.

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:

  1. Reads the version from .claude-plugin/plugin.json.
  2. No-ops if the GitHub release vX.Y.Z already exists (idempotent, so it is safe to re-run after a partial failure).
  3. Extracts that version’s ## [X.Y.Z] section from CHANGELOG.md as the release notes (verbatim: the changelog section is the release notes).
  4. Creates the annotated tag vX.Y.Z (message Release 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:

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