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.