Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ name: CI
on:
push:
branches: [main]
# a version tag publishes the release; see RELEASING.md
tags: ["*"]
pull_request:

env:
Expand All @@ -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
Expand Down Expand Up @@ -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"
61 changes: 61 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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
Loading