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
117 changes: 117 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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"
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading