| name | release-to-crates |
| description | Cut a release of a Rust crate and publish to crates.io, with externally-gated merge and publish steps. |
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 publish is irreversible: a crates.io version can never be
re-uploaded or reused. Always run cargo publish --dry-run first, and only publish
from a revision whose Cargo.toml version 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; use jj for all
commits/bookmarks/push, since raw git commands can corrupt jj state. Otherwise use
plain git.
- 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
name from [package] in Cargo.toml.
Step 1 — Prepare: clean tree on updated trunk, reconcile versions
- Ensure a clean working copy.
- jj:
jj st shows no changes; @ is empty (or run jj new).
- git:
git status clean.
- Update trunk from the remote.
- jj:
jj git fetch; confirm main == main@origin.
- git:
git switch main && git pull --ff-only.
- Establish the two sources of truth and assert they agree:
- 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, then jj bookmark create <user>/bump-version-v<new> -r @.
- git:
git switch -c <user>/bump-version-v<new>.
- Edit
Cargo.toml: set version = "<new>".
- Regenerate the lock file: run
cargo build. This updates the crate's own entry in
Cargo.lock (easy to forget; the published package must carry a consistent lockfile).
- Review the diff — it should touch only
Cargo.toml and Cargo.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
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; then jj new main -m "Publish <crate> <new> to crates.io".
- git:
git switch main && git pull --ff-only.
- Sanity-check:
grep '^version' Cargo.toml shows <new> (the merge brought the bump in).
Step 7 — Publish (publish gate)
- Dry run — validates packaging and runs the verification build without uploading:
cargo publish --dry-run
Warnings about test files "not included in the published package" are benign when
those tests are excluded by the include list in Cargo.toml.
- If the dry run is clean, publish for real:
cargo publish
(Requires a crates.io token: cargo login, or CARGO_REGISTRY_TOKEN in 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 (main HEAD 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 a compare/<lasttag>...v<new> changelog link). Write the body to a file and
pass it with --notes-file to 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> shows draft=false, the expected targetCommitish,
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). Confirm jj st is clean.
- git: nothing to commit; ensure
git status is 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.
jj bookmarks do not auto-advance; create/move the bookmark to @ before pushing.
- New jj bookmarks require
--allow-new on push.
- Forgetting
cargo build leaves Cargo.lock's own package entry stale.
- A crates.io version is permanent — the
--dry-run pre-flight is mandatory, not optional.
- Two irreversible gates need explicit handling: the merge and the publish.
cargo publish does 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 -l before
scoping release notes so a previously-published-but-untagged version is not skipped.