--- 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) 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`): ```bash curl -s -H "User-Agent: -release ()" \ https://crates.io/api/v1/crates/ \ | 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: `/bump-version-v` (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 "` (add a body explaining the bump); then edit, then `jj bookmark create /bump-version-v -r @`. - git: `git switch -c /bump-version-v`. 2. Edit `Cargo.toml`: set `version = ""`. 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 `, 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 --allow-new` (new bookmarks need `--allow-new`). - git: `git push -u origin `. - Open the PR via the forge CLI: ```bash gh pr create --base main --head --title "Bump version to " \ --body "Patch release of \`\`. Non-breaking; bumps Cargo.toml and Cargo.lock from to in preparation for publishing to crates.io. Last published version: ." ``` ## Step 4 — Wait for CI to go green (merge gate) - Watch checks until they complete; the watch exits non-zero if any fail: ```bash gh pr checks --watch --interval 30 ``` - Confirm the final state is mergeable: ```bash gh pr view --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): ```bash gh pr merge --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 to crates.io"`. - git: `git switch main && git pull --ff-only`. - Sanity-check: `grep '^version' Cargo.toml` shows `` (the merge brought the bump in). ## Step 7 — Publish (publish gate) 1. Dry run — validates packaging and runs the verification build without uploading: ```bash 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: ```bash 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 == `. ## 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` (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 (` `) and notes (group commits since that tag by theme; end with a `compare/...v` 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: ```bash gh release create v --target --title " " \ --notes-file --latest ``` 5. Verify: `gh release view v` shows `draft=false`, the expected `targetCommitish`, and the tag (`git ls-remote --tags origin v`) points at ``. 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` | `glab release create v` | ## 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.