Version Bump — version a Team PR at land time
SkillDev toolsBump the semver version in pyproject.toml (major, minor, or patch) and prepend a Keep-a-Changelog entry to CHANGELOG.md. Use when preparing a release, before creating a PR (the version-check CI job blocks merge if you don't), or when the user says "bump version", "release", or "increment version".
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Version Bump — version a Team PR at land time skill
What this skill tells your AI
The instructions your AI receives, as published by agentculture/culture in .claude/skills/version-bump/SKILL.md and read by ahel’s review.
Follow
skills/principle-progress-tracking/SKILL.md: this procedure has more than two steps — seed one todo item per step below before starting and mark each complete as you go.
This skill versions the Team plugin itself at land time. It is dev-only
(lives under .claude/, never distributed to plugin users). Tagging and the
GitHub release are not part of this procedure — release-on-merge.yml does
both automatically when the PR merges. Full policy:
docs/versioning.md.
The dev land process
Landing a Team PR is two steps, in order:
- Bump (this skill). Run
version-bumpagainst currentmain. It picks the level, assigns the next free version, and bumps the six version strings. It cuts the[Unreleased]changelog into a dated## [X.Y.Z]section, runs the land-time consistency assertion, and commitschore(version): X.Y.Z. - Land (the generic
/shipitskill). Run the distributed runtime/shipitskill to push the branch, wait for CI, and squash-merge.shipitis project-agnostic — it does no versioning. This skill is the Team-internal bumper it composes with.
Run this skill before /shipit, against the version of main you intend to
land onto.
Precondition — explicit land intent
Everything below is irreversible-ish work on a shared number: it rewrites six
version strings, moves the [Unreleased] changelog body into a dated section,
retitles the PR, and commits. All of it is computed against the base
branch's tip at this moment, so a bump made any earlier than the land is
stale the moment another PR merges — and the pre-merge guard then denies the
merge until someone recomputes it. Deferring to land time is what keeps the
number correct.
This skill fires only on explicit land intent, meaning one of:
- The user asked to land: "ship it", "land the PR", "land this",
/shipit. - The user asked for the bump itself: "bump the version", "version this PR".
- A
/shipitrun is already in flight and reached its versioning step.
Never infer land intent. None of the following is a cue to bump:
- The work is finished, the review passed, or CI is green.
- A draft PR is about to be opened, or was just opened. A drafted PR carries
no version by design — the bullet goes under
## [Unreleased]and nothing else moves. - The invariant script exited 1. That exit states a precondition for merging, and it is the expected state for a runtime PR's whole review lifetime. It is not a request to bump now.
With no land intent, stop and say so. Report that the branch will need a bump before it can merge, and wait for the user. Do not bump "to be helpful".
The bump is conditional, not universal. Step 0 below decides if this PR
warrants a bump at all. Only PRs that change the distributed plugin bump. A
dev-only PR (CI, docs, tests, evals, .claude/ tooling) lands with no bump and
no changelog cut. Run step 0, see it say "no bump", and go straight to /shipit
with the plain conventional title.
Steps
0. Runtime-vs-dev gate — does this PR warrant a bump at all?
Run this before everything else. Most steps below only apply if it says yes. It answers does this PR warrant a bump, never is now the right time — the land-intent precondition above already settled the timing, and a yes here does not reopen it.
The version, changelog, and GitHub release exist for plugin end users —
people who install Team and run /team. They are driven only by changes to the
distributed plugin. Contributor-facing / plugin-developer infrastructure does
not move the version, no matter what conventional-commit type it carries.
Using the Runtime vs. Development split in CLAUDE.md:
- Runtime (bump-worthy):
agents/,skills/,hooks/, and host manifest content —.claude-plugin/,.codex-plugin/,.agents/plugins/— meaning a real change to a manifest, not the bare"version"field. Every host's manifest ships to that host's end users, so a Codex-only manifest change is as much a runtime change as a Claude Code one. - Development (never bumps):
.github/,.claude/,docs/,tests/,evals/,package.json/bun.locktooling — everything that only validates or builds the plugin.
Take a quick orientation look at what this PR actually changed:
git diff origin/main...HEAD --name-only
The quick look is orientation only — it never decides the exit. The decision comes from the invariant run, the same invocation contract the pre-merge guard enforces at merge time:
- Resolve the default branch by asking GitHub (
gh repo view), falling back to the localorigin/HEADref — the same order the pre-merge guard uses: GitHub is authoritative, and the local ref goes stale on an upstream default-branch rename. Never a guessedmain, which could measure against the wrong base. If both fail, stop: no verdict. git fetch origin <default>— the fetch must succeed. Never degrade to a stale base for a verdict.- Up-to-date precondition: the fetched
origin/<default>tip must be an ancestor of the branch tip. If the branch is behind, stop: rebase ontoorigin/<default>and re-enter step 0 — a rebase can change both verdict inputs. - Run the script with
HEAD_SHA= the local branch tip andBASE_SHA= the fetchedorigin/<default>tip — the tip, never a pre-computed merge-base, and never a hand-rolled two-dot diff (the script reduces the pair to the fork point itself).
DEFAULT=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null)
DEFAULT=${DEFAULT:-$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|^refs/remotes/origin/||')}
[ -n "$DEFAULT" ] || { echo "cannot resolve the default branch — no verdict"; exit 1; }
git fetch origin "$DEFAULT" || { echo "fetch failed — no verdict"; exit 1; }
git merge-base --is-ancestor "refs/remotes/origin/$DEFAULT" HEAD \
|| { echo "behind base — rebase onto origin/$DEFAULT, re-enter step 0"; exit 1; }
HEAD_SHA=$(git rev-parse HEAD) BASE_SHA=$(git rev-parse "refs/remotes/origin/$DEFAULT") \
.github/scripts/version-bump-required.sh
Read the outcome by exact output match — the signal rule, default-deny:
- Exit 0, stdout starting
OK: runtime_changed=false bumped=false→ dev-only and final. DO NOT BUMP. Skip every step below. Leave the version untouched, do not cut the changelog, and land with the plain conventional title (<type>: <subject>). On a re-entry whose PR title still carries a stalevX.Y.Zprefix from an earlier bump, strip it now (gh pr edit --title) — the title backstop never strips a stale prefix, and this is the one step-8 action a no-bump exit still owes (the wrongful- bump recovery in docs/versioning.md lands here). Precedent:710d44c(CI),7d2e218(docs),0821129(evalsfeat:) all landed plain. Then go straight to/shipit. This exit requires that OK line — the quick look alone never authorizes it. - Exit 0, stdout starting
OK: runtime_changed=true bumped=true→ already bumped (a recovery re-entry). Never re-bump — proceed to/shipit. - Exit 1, verdict containing
cannot merge until version-bump runs at land time→ bump warranted. Reaching this line means the land-intent precondition already passed, which is the only reason the verdict is actionable here; read outside a land it states a merge precondition and nothing more.- On a branch with no
chore(version)commit: continue to step 1. This signal reports an unmet merge precondition on a branch that has not bumped, which is exactly the state step 1 exists to change. Every other exit-1 verdict in this list stops. - On a branch already carrying a
chore(version)commit: the bump went stale (a rebase moved the fork point). Stop — drop the bump commit, undo the changelog cut, reset the title, and re-enter step 0.
- On a branch with no
- Exit 1, verdict containing
must land with no bump→ wrongful bump. Stop — drop the commit, undo the cut, and re-run step 0. - Anything else — a non-semver version, a merge-base or diff failure, or unrecognized output — stops in both directions. Surface the message verbatim. A hard script error never means "keep going".
This is a hard gate, not a judgment call. The check runs early here (this step
and step 7, while recovery is still purely local) and is enforced mechanically
at merge time by the dev pre-merge guard
(.claude/hooks/pre-merge-guard.mjs), which denies a gh pr merge on either
violation: a dev-only diff that bumped, or a runtime diff that did not. The
script itself stays pinned by tests/version-bump-required.test.ts.
1. Decide the bump level
Reached only when step 0 said a bump is warranted (the PR changed runtime files). The level question is never "does this bump?" — step 0 already answered that — only "how big is the bump?"
git log origin/main..HEAD --oneline
git diff origin/main...HEAD --stat
What SemVer actually says
SemVer 2.0.0, quoted verbatim. Note the
scope column — items 6, 7, and 8 each carry an x > 0 precondition:
| Item | Normative rule | Scope |
|---|---|---|
| 6 | PATCH "MUST be incremented if only backward compatible bug fixes are introduced. A bug fix is defined as an internal change that fixes incorrect behavior." | x.y.Z | x > 0 |
| 7 | MINOR "MUST be incremented if new, backward compatible functionality is introduced to the public API." | x.Y.z | x > 0 |
| 8 | MAJOR "MUST be incremented if any backward incompatible changes are introduced to the public API." | X.y.z | X > 0 |
Team's version starts 0., so not one of those three rules binds. Item 4
governs instead: "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 at all pre-1.0, which is why the rule
below is Team's own convention — chosen so it keeps meaning the same thing once
1.0.0 arrives. Item 5: "Version 1.0.0 defines the public API."
The decision
Ask these in order. The first yes wins. Judge the change, never the commit subject:
-
Can a plugin user observe the difference? → minor
A user installs Team and runs its commands. Anything that changes what they type, what they get back, or what the plugin does on their behalf is observable: a command's name or arguments (
argument-hint), documented behavior, whether a step prompts them, an artifact's format or frontmatter schema, hook behavior, an agent's model or tool access. New capability and changed capability both land here. -
Otherwise → patch
Internal-only and backward compatible: prose that clarifies without changing an instruction, a comment, restructuring that preserves behavior. This is item 6's definition — "an internal change that fixes incorrect behavior" — and it requires both qualifiers, not just a
fix:subject.Expect patch to be rare. Team ships prose that a model reads, so a runtime edit usually changes what the plugin does, and question 1 catches it. That is the intended consequence of this rule, not evidence it is miscalibrated — do not widen patch to make the cadence feel familiar.
major is unreachable while the version starts 0. Item 8 is scoped
X > 0, and 1.0.0 is the release that "defines the public API" (item 5). A
breaking change pre-1.0 is a minor, not a major: bumping to 1.0.0 to
describe one broken interface would commit the whole plugin to API stability,
which is a far larger claim than the change makes. If a change looks like it
warrants major, that is a signal to ask whether it is time to declare 1.0.0
— a deliberate decision, never a side effect of this skill.
The commit type is not the input
A conventional-commit type describes the author's intent, not the blast radius,
so it never decides the level. A fix: that changes observable behavior is a
minor; a feat: confined to internals is a patch. Step 0 has already
settled whether to bump, so a ci:/test:/docs:/chore: commit shipping
no runtime change never reaches this decision at all.
Worked example — PR #228,
which this rule exists to get right. fix(shipit): merge without stopping for approval removed the --yes argument and removed the pre-merge confirmation.
Question 1: a user who typed /shipit stopped being asked to confirm, and a
documented argument disappeared — observable. minor (0.43.2 → 0.44.0). The
fix: subject is irrelevant, and the removed argument does not make it a major
while Team is pre-1.0.
State the chosen level and which question decided it. Both levels are reachable from any commit type, so a level that needed a judgment call is a signal the observability question above was not actually answered — answer it rather than asking the user.
2. Compute the next version
bash .claude/scripts/next-version.sh <level>
This prints bump(<default branch>'s version, level) — deterministic, a pure
function of the base and the level, with no open-PR scan. The base is read from
the remote's default branch (resolved through origin/HEAD, not a hardcoded
main). Under the land-time model the version is assigned against current main
and landing is serialized, so bump(main, level) is always free. A concurrent
race resolves at merge time: /shipit rebases the branch, the pre-merge guard
denies the now-stale bump, and the recovery (step 0's stale-bump signal)
recomputes. release-on-merge.yml's duplicate-tag rejection backstops the rest.
3. Bump all six version strings
The version lives in six places across five files:
| File | Occurrences |
|---|---|
.claude-plugin/plugin.json | 1 (version) |
.claude-plugin/marketplace.json | 2 (metadata.version and plugins[0].version) |
.codex-plugin/plugin.json | 1 (version) |
package.json | 1 (version) |
plugin.json (repo root) | 1 (version): what Antigravity reports |
Codex reads .codex-plugin/plugin.json in preference to the Claude manifest,
and shows the version it finds there. A stale one makes the same release look
like two different versions depending on the host. Antigravity reads the root
plugin.json, which sits at the root rather than in a directory of its own
because that host resolves skills/ and agents/ as siblings of its manifest.
Edit all five files, then prove it:
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 new version. Zero may still show the old one.
4. Cut the changelog section
This moves the accumulated [Unreleased] body into a new dated section. It
is the inverse of release-on-merge.yml's awk extraction, because you write
the section the release workflow later reads. In CHANGELOG.md (Keep a Changelog
format, entry style per skills/changelog/SKILL.md):
- Move the entire
[Unreleased]body into a new## [X.Y.Z] - YYYY-MM-DD(today's date) section inserted directly below## [Unreleased]. Leave## [Unreleased]in place, now empty again. - Re-point 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
This section becomes the GitHub release notes verbatim — write it for a reader
deciding if the upgrade is worth it. Any links must be absolute URLs: relative paths (e.g.
docs/versioning.md) render as dead links on the release page (see
skills/changelog/SKILL.md).
Empty-[Unreleased] edge case. A PR that reached this step passed step 0, so
it did change runtime files. An empty [Unreleased] on it means nobody wrote
the user-facing bullet.
Derive at least one bullet from the PR's runtime commits
(feat:/fix:/perf:/security: per skills/changelog/SKILL.md style). Never
write an empty section (release-on-merge.yml errors on empty release notes).
Empty [Unreleased] and no runtime change is not this case. That PR must
have stopped at step 0, with no bump and no changelog cut. Do not invent a
bullet to justify a bump that step 0 already declined. Go back and land plain.
5. Land-time consistency assertion
After the changelog cut and before committing, run the consistency check —
it must run after the cut (so the dated section exists to validate). This is
the in-tree replacement for the retired version-gate.yml:
bun test tests/version-consistency.test.ts
node -e "['.claude-plugin/plugin.json','.claude-plugin/marketplace.json','.codex-plugin/plugin.json','.agents/plugins/marketplace.json','plugin.json','package.json'].forEach(f=>JSON.parse(require('fs').readFileSync(f)));console.log('JSON OK')"
The tripwire asserts strict semver, that all six strings agree, and that the host manifests agree on the plugin and marketplace names. Additionally assert inline the released-section + footer-compare-link invariants (these hold only after the cut, so they live here, not in the tripwire):
V=$(jq -r .version .claude-plugin/plugin.json)
ESC=$(sed 's/\./\\./g' <<<"$V")
grep -qE "^## \[$ESC\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$" CHANGELOG.md \
|| { echo "::error::no '## [$V] - YYYY-MM-DD' section — the cut did not land"; exit 1; }
grep -qE "^\[$ESC\]: https://" CHANGELOG.md \
|| { echo "::error::no footer compare link for $V"; exit 1; }
grep -q "\[Unreleased\]: https://github.com/bostonaholic/team/compare/v$V...HEAD" CHANGELOG.md \
|| { echo "::error::[Unreleased] footer does not compare from v$V"; exit 1; }
echo "OK: land-time consistency holds"
If any check fails, stop before committing and fix the cut.
6. Commit
Commit the bump as its own commit in the PR branch, for clean reverts:
git add .claude-plugin/plugin.json .claude-plugin/marketplace.json \
.codex-plugin/plugin.json plugin.json package.json CHANGELOG.md
git commit -m "chore(version): X.Y.Z"
7. Assert the bump invariant
Re-run the invariant run from step 0 — the head is now the branch tip carrying
the chore(version) commit. Require exit 0 with stdout starting
OK: runtime_changed=true bumped=true.
This runs before any remote change (the title edit in step 8 is remote).
On any other outcome, stop: drop the chore(version) commit, undo the
changelog cut, and land plain — nothing has left the machine, so the recovery
is purely local.
8. Title the PR
vX.Y.Z <type>: <subject> — e.g. v0.6.0 feat: add the shipit land skill. Set
it on the existing PR (gh pr edit --title). The PR title sync workflow
corrects drift, but it is a backstop — do not rely on it.
A re-entry path that runs after a title already exists must reset it: the
stale-bump recovery re-titles with the recomputed version, and a re-entry that
ends at "no bump" strips the vX.Y.Z prefix explicitly — the title backstop
never strips a stale prefix.
Then run /shipit (step 2 of the dev land process) to push, wait for CI, and
squash-merge.
Signals
- GitHub stars
- 113
- Forks
- 6
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
version-bump- Source
- github.com/agentculture/culture