From 054bdc88d64718fe256208265a8a5231b1474dc4 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 15 Oct 2026 12:50:00 -0700 Subject: [PATCH] Publish releases from version tags and document the procedure Pushing a tag X.Y.Z runs every check, then publishes to crates.io and opens a draft GitHub release with that version's changelog section as the notes. The release fails early if the tag doesn't match Cargo.toml, CHANGELOG.md has no section for it, or the tag isn't on main. Each step skips work already done, so rerunning a failed release is safe. Publishing uses crates.io trusted publishing, so no API token is stored. PRs now run cargo publish --dry-run to catch packaging problems early. RELEASING.md describes the procedure. --- .github/workflows/ci.yml | 64 ++++++++++++++++++++++++++++++++++++++++ RELEASING.md | 61 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 RELEASING.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41e8fc5..0e8aacc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,8 @@ name: CI on: push: branches: [main] + # a version tag publishes the release; see RELEASING.md + tags: ["*"] pull_request: env: @@ -19,6 +21,8 @@ jobs: - run: cargo fmt --check - run: cargo clippy - run: cargo test + # catch packaging problems on PRs instead of at release time + - run: cargo publish --dry-run no-panic: name: no-panic link check @@ -49,3 +53,63 @@ jobs: - uses: dtolnay/rust-toolchain@1.77 # build only: dev-dependencies require a newer toolchain - run: cargo build + + # Runs only for tags, after every check above passes. See RELEASING.md. + release: + needs: [test, no-panic, msrv] + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: write # create the draft GitHub release + id-token: write # crates.io trusted publishing + env: + VERSION: ${{ github.ref_name }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # needed to check that the tag is on main + - uses: dtolnay/rust-toolchain@stable + - name: Check the tag against Cargo.toml, CHANGELOG.md, and main + run: | + manifest=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1) + if [ "$manifest" != "$VERSION" ]; then + echo "::error::tag $VERSION does not match the Cargo.toml version $manifest" + exit 1 + fi + if ! grep -qxF "### $VERSION ###" CHANGELOG.md; then + echo "::error::CHANGELOG.md has no '### $VERSION ###' section" + exit 1 + fi + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::tag $VERSION is not on main" + exit 1 + fi + # each step below skips work that is already done, so rerunning a failed release is safe + - name: Check whether this version is already on crates.io + id: crates + run: | + if curl -fs -A "scursor-release (https://github.com/stepfunc/scursor)" \ + "https://crates.io/api/v1/crates/scursor/$VERSION" > /dev/null; then + echo "scursor $VERSION is already on crates.io, skipping publish" + echo "published=true" >> "$GITHUB_OUTPUT" + fi + - uses: rust-lang/crates-io-auth-action@v1 + if: steps.crates.outputs.published != 'true' + id: auth + - name: Publish to crates.io + if: steps.crates.outputs.published != 'true' + run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + - name: Create a draft GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$VERSION" > /dev/null 2>&1; then + echo "release $VERSION already exists, skipping" + exit 0 + fi + # release notes are this version's CHANGELOG.md section + awk -v heading="### $VERSION ###" '$0 == heading {found = 1; next} /^### / && found {exit} found' \ + CHANGELOG.md > "$RUNNER_TEMP/notes.md" + gh release create "$VERSION" --draft --verify-tag --title "$VERSION" --notes-file "$RUNNER_TEMP/notes.md" diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..6d5a3cc --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,61 @@ +# Releasing + +A release is driven by **pushing a git tag**. When a tag `X.Y.Z` is pushed, CI +(`.github/workflows/ci.yml`) runs every check, then publishes that version to +crates.io and opens a draft GitHub release. Your job is to land a release commit +on `main`, then tag it. + +## 1. Prepare a `Release X.Y.Z` PR against `main` + +- Set `version` in `Cargo.toml` to `X.Y.Z`. +- Make sure the top of `CHANGELOG.md` is a `### X.Y.Z ###` section describing the + user-facing changes since the last release (see the conventions below). Its + contents become the GitHub release notes. +- Decide whether the release is a patch or minor version. This crate is `0.x`, so + a breaking change bumps the minor version (`0.5.1` → `0.6.0`) and anything else + bumps the patch version. + +Open the PR, get CI green, and merge it. CI runs `cargo publish --dry-run` on +every PR, so packaging problems show up here rather than at release time. + +## 2. Tag the release on `main` + +The tag must be the bare version (no `v` prefix), and must match `Cargo.toml` +exactly: + +```bash +git checkout main && git pull +git tag -a X.Y.Z -m "Release X.Y.Z" +git push origin X.Y.Z +``` + +**Publishing to crates.io is irreversible.** A version can be yanked but never +replaced, so check the version before pushing the tag. + +Before publishing, CI fails the release if the tag doesn't match the +`Cargo.toml` version, if `CHANGELOG.md` has no `### X.Y.Z ###` section, or if the +tagged commit isn't on `main`. + +## 3. Finish up + +- Watch the tag's CI run. Each release step skips work that's already done, so + if something fails partway, rerunning the workflow is safe. +- Review the **draft** GitHub release CI created, then publish it. + +## CHANGELOG conventions + +- One `### X.Y.Z ###` section per release, newest first. +- Bullet prefixes: `:star:` feature, `:bug:` bug fix, `:book:` documentation, + `:wrench:` tooling and CI. +- Reference the issue or PR, e.g. `See [#16](https://github.com/stepfunc/scursor/issues/16).` + +## One-time setup: crates.io trusted publishing + +CI publishes with [trusted publishing](https://crates.io/docs/trusted-publishing), +so no API token is stored in GitHub. A crate owner configures it once on +crates.io, under the crate's settings, with: + +- Repository owner: `stepfunc` +- Repository name: `scursor` +- Workflow filename: `ci.yml` +- Environment: leave blank