Make the version-bump edits directly. A release bump is a mechanical release-engineering operation, not feature work, so it does not need to be routed through whatever feature-delegation process the repo otherwise follows.
When to use
- The user asks to cut a release, bump the crate version, and/or publish to crates.io.
- Default the bump to patch for a non-breaking change. For a breaking change, choose minor/major per semver, then confirm the chosen level with the user before proceeding.
Hard gates (do not skip)
- Merge gate — never merge the release PR without green CI; confirm with the user before merging unless they have explicitly pre-authorized an auto-merge.
- Publish gate —
cargo publishis irreversible: a crates.io version can never be re-uploaded or reused. Always runcargo publish --dry-runfirst, and only publish from a revision whoseCargo.tomlversion is confirmed not yet on crates.io.
Detect the environment first
These three facts drive every branching step below:
- VCS: if
.jj/exists → this is a colocated jujutsu/git repo; usejjfor all commits/bookmarks/push, since rawgitcommands can corrupt jj state. Otherwise use plaingit. - Forge CLI: GitHub →
gh. GitLab →glab. If the required forge CLI is not on PATH, stop and ask the human — do not improvise a web/manual flow. - Crate name: read
namefrom[package]inCargo.toml.
Step 1 — Prepare: clean tree on updated trunk, reconcile versions
- Ensure a clean working copy.
- jj:
jj stshows no changes;@is empty (or runjj new). - git:
git statusclean.
- jj:
- Update trunk from the remote.
- jj:
jj git fetch; confirmmain == main@origin. - git:
git switch main && git pull --ff-only.
- jj:
- Establish the two sources of truth and assert they agree:
- Local:
versionunder[package]inCargo.toml. - Published: query the registry (needs a
User-Agent):
(A brand-new crate returns nocurl -s -H "User-Agent: <crate>-release (<you@example.com>)" \ https://crates.io/api/v1/crates/<crate> \ | python3 -c "import sys,json; print(json.load(sys.stdin)['crate']['max_version'])"crateobject — treat as "never published".) - If
Cargo.tomlversion != crates.iomax_version, stop and report — the repo is in an unexpected state (a prior release was half-finished or the tree is stale).
- Local:
- Compute the next version (patch by default) and the branch name.
Naming convention:
<user>/bump-version-v<new-version>(e.g.danver/bump-version-v0.9.9).
Step 2 — Commit the bump on a release branch
- Create the revision/bookmark with a message up front.
- jj:
jj desc -m "Bump version to <new>"(add a body explaining the bump); then edit, thenjj bookmark create <user>/bump-version-v<new> -r @. - git:
git switch -c <user>/bump-version-v<new>.
- jj:
- Edit
Cargo.toml: setversion = "<new>". - Regenerate the lock file: run
cargo build. This updates the crate's own entry inCargo.lock(easy to forget; the published package must carry a consistent lockfile). - Review the diff — it should touch only
Cargo.tomlandCargo.lock, both just the version line.jj diff --git/git diff. - Commit message: imperative subject
Bump version to <new>, no trailing period, no Conventional-Commits prefix; optional body noting it's a non-breaking patch release.
Step 3 — Push and open the PR
- Push the branch:
- jj:
jj git push -b <bookmark> --allow-new(new bookmarks need--allow-new). - git:
git push -u origin <branch>.
- jj:
- Open the PR via the forge CLI:
gh pr create --base main --head <branch> --title "Bump version to <new>" \ --body "Patch release of \`<crate>\`. Non-breaking; bumps Cargo.toml and Cargo.lock from <old> to <new> in preparation for publishing to crates.io. Last published version: <old>."
Step 4 — Wait for CI to go green (merge gate)
- Watch checks until they complete; the watch exits non-zero if any fail:
gh pr checks <pr> --watch --interval 30 - Confirm the final state is mergeable:
gh pr view <pr> --json state,mergeable,mergeStateStatus - If any check fails: stop, report the failing check, and do not merge.
Step 5 — Merge (confirm with the human)
- Unless the user pre-authorized auto-merge, confirm before merging.
- Merge matching the repo's history style (this repo uses merge commits):
gh pr merge <pr> --merge - Verify
state=MERGED.
Step 6 — Re-sync trunk and stage a clean publish revision
- Pull the merged trunk:
- jj:
jj git fetch; thenjj new main -m "Publish <crate> <new> to crates.io". - git:
git switch main && git pull --ff-only.
- jj:
- Sanity-check:
grep '^version' Cargo.tomlshows<new>(the merge brought the bump in).
Step 7 — Publish (publish gate)
- Dry run — validates packaging and runs the verification build without uploading:
Warnings about test files "not included in the published package" are benign when those tests are excluded by thecargo publish --dry-runincludelist inCargo.toml. - If the dry run is clean, publish for real:
(Requires a crates.io token:cargo publishcargo login, orCARGO_REGISTRY_TOKENin the env.) - Verify it went live: re-query the registry and confirm
max_version == <new>.
Step 8 — Tag a forge release
Publishing to crates.io does not create a git tag or a forge (GitHub/GitLab) release — do this explicitly so the version is reflected in the repo's history.
- Pick the tag: convention is
v<new>(e.g.v0.9.6). Target the merged trunk commit that carries the new version (mainHEAD from Step 6), referenced by its full SHA. - Decide the notes' scope. List existing tags (
git tag -l | sort -V) and find the most recent one that actually has a forge release. If earlier published versions were never tagged, scope the changelog from the last released tag and say so in the body, so the skipped versions are not lost. - Draft a title (
<Crate> <new>) and notes (group commits since that tag by theme; end with acompare/<lasttag>...v<new>changelog link). Write the body to a file and pass it with--notes-fileto avoid shell-quoting issues. - Create the release via the forge CLI — this creates the tag on the remote directly:
gh release create v<new> --target <main-sha> --title "<Crate> <new>" \ --notes-file <notes-file> --latest - Verify:
gh release view v<new>showsdraft=false, the expectedtargetCommitish, and the tag (git ls-remote --tags origin v<new>) points at<main-sha>.
Note for jj repos: gh release create creates the tag on the GitHub side, so it does
not touch local jj/git refs and is safe to run from a colocated repo.
Step 9 — Leave a clean repo
- jj: abandon the now-empty publish revision so trunk is the head with a fresh empty
@:jj abandon @(publishing changes no tracked files). Confirmjj stis clean. - git: nothing to commit; ensure
git statusis clean.
Provider seams (for the skill abstraction)
Each step maps to a swappable provider so the skill can support other stacks:
| Concern | GitHub + jj (this run) | git-only / GitLab variant |
|---|---|---|
| Commit/branch | jj desc + jj bookmark create |
git switch -c |
| Push | jj git push -b … --allow-new |
git push -u origin … |
| Open PR/MR | gh pr create |
glab mr create |
| CI gate | gh pr checks --watch |
glab ci status / pipeline poll |
| Merge | gh pr merge --merge |
glab mr merge |
| Version truth | Cargo.toml ↔ crates.io API |
same |
| Publish | cargo publish (+ --dry-run) |
same |
| Tag release | gh release create v<new> |
glab release create v<new> |
Pitfalls learned in the first manual run
- The crates.io API returns 200 with an error body if you omit a
User-Agent; always send one. jjbookmarks do not auto-advance; create/move the bookmark to@before pushing.- New jj bookmarks require
--allow-newon push. - Forgetting
cargo buildleavesCargo.lock's own package entry stale. - A crates.io version is permanent — the
--dry-runpre-flight is mandatory, not optional. - Two irreversible gates need explicit handling: the merge and the publish.
cargo publishdoes not tag the repo; the forge release (Step 8) is a separate step.- A version can be live on crates.io yet have no forge tag — check
git tag -lbefore scoping release notes so a previously-published-but-untagged version is not skipped.