From f016b483b649ccd9a27b067dc22da67fd653d7a5 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 29 Jul 2026 08:12:29 +0700 Subject: [PATCH] Publish to crates.io when the version changes Releases were manual: bump, merge, then remember to run cargo publish from a clean checkout. The remembering is the part that fails. Merging a Cargo.toml version bump to master now publishes that version and tags the commit. Built around the fact that a publish cannot be undone and a version number can never be reused: - The registry, not a diff, decides whether work is needed. A rerun, a revert or a manual publish all leave a diff misleading and the registry correct, so an accidental rerun is a no-op rather than a failure. - Only Cargo.toml triggers it, since only Cargo.toml carries the version, so an ordinary merge publishes nothing. - A concurrency group serializes it, so two merges in quick succession cannot race each other into the registry. - The full check suite runs inside the publish job rather than trusting the CI workflow to have finished. Both fire on the same push and nothing orders them. - The tag is pushed after a successful publish, so it never points at a version that failed to go out. - A missing token fails with its name and where to add it, rather than as an authentication error from cargo part way through a release. The token is read through the environment rather than interpolated into a command, so it cannot reach a shell trace. Both branches of the version check were run locally against the real registry before this was committed: 0.2.1 correctly reads as needing publication and 0.2.0 as already done. --- .github/workflows/publish.yml | 117 ++++++++++++++++++++++++++++++++++ AGENTS.md | 18 ++++++ 2 files changed, 135 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..f519bba --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,117 @@ +name: Publish + +# Publishes to crates.io when the version in Cargo.toml changes and that version +# is not already on the registry. +# +# Safe to re-run. A publish is permanent and a version number can never be +# reused, so this checks the registry first and does nothing when the version is +# already there. That makes an accidental re-run a no-op rather than a failure, +# and means a merge that does not touch the version publishes nothing. +on: + push: + branches: [master] + # Only the manifest carries the version, so nothing else can trigger this. + paths: ["Cargo.toml"] + # For re-running after fixing a missing token, without an empty commit. + workflow_dispatch: + +# One publish at a time. Two merges in quick succession must not race each +# other into the registry. +concurrency: + group: publish + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings + +jobs: + publish: + name: publish to crates.io + runs-on: ubicloud-standard-2 + permissions: + # For pushing the version tag. + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Read the version being released + id: version + run: | + version=$(grep -m1 '^version = ' Cargo.toml | sed 's/.*"\(.*\)".*/\1/') + if [ -z "$version" ]; then + echo "could not read a version out of Cargo.toml" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Cargo.toml declares $version" + + # The registry is the source of truth for what exists, rather than a diff + # against the previous commit: a rerun, a revert or a manual publish all + # leave the diff misleading and the registry correct. + - name: Is that version already on crates.io? + id: check + run: | + published=$(curl -sS -H 'User-Agent: agent-abstraction release workflow' \ + https://crates.io/api/v1/crates/agent-abstraction \ + | python3 -c "import json,sys; print(' '.join(v['num'] for v in json.load(sys.stdin).get('versions', [])))") + echo "on crates.io: $published" + if echo " $published " | grep -q " ${{ steps.version.outputs.version }} "; then + echo "needed=false" >> "$GITHUB_OUTPUT" + echo "::notice::${{ steps.version.outputs.version }} is already published; nothing to do" + else + echo "needed=true" >> "$GITHUB_OUTPUT" + fi + + # Checked explicitly so a missing secret says so, rather than surfacing as + # an authentication error from cargo half way through a release. + - name: Is the registry token configured? + if: steps.check.outputs.needed == 'true' + # Read through the environment rather than interpolated into the command + # itself, so the value never appears in a command line that a shell + # trace or a crash dump could carry. + env: + TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: | + if [ -z "$TOKEN" ]; then + echo "CARGO_REGISTRY_TOKEN is not set for this repository." >&2 + echo "Create one at https://crates.io/settings/tokens with publish-update scope," >&2 + echo "then add it under Settings > Secrets and variables > Actions." >&2 + exit 1 + fi + + - name: Install the toolchain + if: steps.check.outputs.needed == 'true' + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + if: steps.check.outputs.needed == 'true' + + # Re-verified here rather than trusting the CI workflow to have finished. + # Both run on the same push, so nothing orders them, and a publish is the + # one action that cannot be taken back. + - name: Verify before publishing + if: steps.check.outputs.needed == 'true' + run: | + cargo fmt --all --check + cargo clippy --all-targets -- -D warnings + cargo test --all-targets + cargo test --doc + cargo package + + - name: Publish + if: steps.check.outputs.needed == 'true' + run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + # After publishing, so a tag never points at a version that failed to go + # out. The tag is what maps a registry version back to a commit. + - name: Tag the release + if: steps.check.outputs.needed == 'true' + run: | + tag="v${{ steps.version.outputs.version }}" + git tag -a "$tag" -m "$tag" + git push origin "$tag" diff --git a/AGENTS.md b/AGENTS.md index 455d1c7..a80ff06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,24 @@ skips itself when its binary is absent. **Run it after touching any argv mapping parser**. The unit tests prove the code does what it says, only the live suite proves the CLI agrees. +## Releasing + +Publishing is automatic: merging a version bump in `Cargo.toml` to `master` publishes that +version to crates.io and tags the commit. Nothing else triggers it, and a version already on +the registry is a no-op, so a rerun or a revert cannot double-publish. + +Two consequences worth holding on to: + +- **A version bump is a release.** There is no staging step between merging one and it being + permanent on crates.io, where a version number can never be reused. Bump the version in the + commit you intend to ship, not ahead of it. +- **The publish job re-runs the full check suite itself** rather than trusting the CI + workflow. Both fire on the same push and nothing orders them, and a publish is the one + action that cannot be taken back. + +Requires the `CARGO_REGISTRY_TOKEN` repository secret. Without it the job fails with that +name in the message rather than an opaque auth error from cargo. + ## Architecture Pure logic and I/O are kept apart so the mappings are testable without spawning anything.