Versioning
Versioning
Audience: Plugin maintainers and contributors. End users do not need this — it describes how the Team plugin itself is versioned. Nothing here applies to projects that merely use the plugin.
Team versions per pull request: every PR that lands on main
bumps the plugin version — features, fixes, chores, docs, all of them. There
is no batch release step; the merge is the release. CI tags and publishes
automatically.
Policy
One PR = one version. Pick the bump level from the highest-impact change in the PR (3-part SemVer):
| Level | When |
|---|---|
major (X.0.0) |
Breaking change to the plugin’s contract (commands, artifact formats, hook behavior) |
minor (x.Y.0) |
New backward-compatible capability (feat:) |
patch (x.y.Z) |
Everything else — fix:, docs:, chore:, refactor:, test:, ci: |
No PR merges without a bump. The Version gate CI check blocks unbumped PRs.
The four version strings
The version lives in four places across three files. Forgetting one ships an internally inconsistent tree — 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) |
package.json |
1 (version) |
One grep proves consistency:
grep -rn '"version"' package.json .claude-plugin/plugin.json .claude-plugin/marketplace.json
All four lines must show the same version. tests/version-consistency.test.ts
enforces this on every bun test run.
Picking the next free version
Parallel workspaces mean parallel open PRs, and two PRs must never claim the same version. Compute yours with the helper:
.claude/scripts/next-version.sh <major|minor|patch>
It bumps from origin/main’s version, then walks past any version already
claimed by another open PR (read from each PR head’s plugin.json via the
GitHub API; fail-open if the API is unreachable). Gaps in the sequence are
fine; collisions are not.
Collision resolution: the older PR (lower number) keeps the slot. If
the gate reports your version is claimed by an older PR: rebase on main,
re-run next-version.sh, re-bump, force-push. The gate passes the older PR
and fails only the newer one, so exactly one side has to move.
Changelog: one section per PR
Each PR inserts its own released section — entries never accumulate under
[Unreleased]:
- Insert
## [X.Y.Z] - YYYY-MM-DD(today’s date) directly below## [Unreleased], with the PR’s### Added/### Changed/### Fixedentries.## [Unreleased]stays in place, permanently empty. - 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
Entry style follows skills/changelog/SKILL.md (Keep a Changelog, plain
prose, user-facing). Because the section is the release notes (see below),
write it for a reader deciding whether to upgrade.
PR title
PR titles carry the version prefix:
vX.Y.Z <type>: <subject>
e.g. v0.5.0 feat: adopt per-PR versioning with CI gate and auto-release.
The PR title sync workflow rewrites drifted titles to match the PR head’s
plugin.json — but set it correctly yourself; the sync is a backstop, not
the mechanism.
What CI enforces, and where
Per TESTING.md, every check lives at the cheapest layer that can catch it:
| Check | Layer | Where |
|---|---|---|
| Four version strings agree; strict semver; changelog section + footer links exist for the current version | L2 tripwire (free, every bun test) |
tests/version-consistency.test.ts |
| Version bumped vs base; valid increment shape; collision with other open PRs | CI (needs git/API context) | .github/workflows/version-gate.yml |
| Title prefix matches the version | 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 |
Known race, accepted: the version gate re-runs when your PR changes
(synchronize), not when another PR merges. The mitigation is the branch
protection setting “Require branches to be up to date before merging” with
Version gate as a required check — the forced rebase triggers a re-run
against the new base. Backstop: release-on-merge.yml fails loudly if a
merged version’s tag already exists, so a slipped duplicate is detected, never
silently lost.
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 — 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.
Recovery
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.
Safe only if no commits landed after the broken one — confirm origin/main
still equals your pre-amend commit first. (This should be near-impossible now:
the bun test and the version gate both check string agreement before 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.
- TESTING.md — why each check lives at its layer.