CtrlK
BlogDocsLog inGet started
Tessl Logo
SkillPublic

release-to-crates

Cut a release of a Rust crate and publish to crates.io, with externally-gated merge and publish steps.

Quality

83%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

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)

  1. 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.
  2. 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

  1. Ensure a clean working copy.
    • jj: jj st shows no changes; @ is empty (or run jj new).
    • git: git status clean.
  2. Update trunk from the remote.
    • jj: jj git fetch; confirm main == main@origin.
    • git: git switch main && git pull --ff-only.
  3. Establish the two sources of truth and assert they agree:
    • Local: version under [package] in Cargo.toml.
    • Published: query the registry (needs a User-Agent):
      curl -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'])"
      (A brand-new crate returns no crate object — treat as "never published".)
    • If Cargo.toml version != crates.io max_version, stop and report — the repo is in an unexpected state (a prior release was half-finished or the tree is stale).
  4. 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

  1. 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>.
  2. Edit Cargo.toml: set version = "<new>".
  3. 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).
  4. Review the diff — it should touch only Cargo.toml and Cargo.lock, both just the version line. jj diff --git / git diff.
  5. 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>.
  • 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; 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)

  1. 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.
  2. 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.)
  3. 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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
  5. 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:

ConcernGitHub + jj (this run)git-only / GitLab variant
Commit/branchjj desc + jj bookmark creategit switch -c
Pushjj git push -b … --allow-newgit push -u origin …
Open PR/MRgh pr createglab mr create
CI gategh pr checks --watchglab ci status / pipeline poll
Mergegh pr merge --mergeglab mr merge
Version truthCargo.toml ↔ crates.io APIsame
Publishcargo publish (+ --dry-run)same
Tag releasegh 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.
Repository

imbue-ai/offload

Path

.agents/skills/release-to-crates/SKILL.md

Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.