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