From 34016701f3471973267081ca86065ca5811c4caa Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Fri, 31 Jul 2026 16:39:41 -0600 Subject: [PATCH] ci: publish from a tag, so crates.io always maps to a commit During the assessment behind this issue, local main was seven commits behind the crates.io state: 0.5.0, 0.4.0, and 0.2.0 had been published from a workstation while the tree still said 0.4.0, 0.3.0, and 0.1.1. Release tags existed, but nothing tied a published version to a commit anyone could look at. Publishing now happens in CI and only in CI, keyed on -v tags, the format already in this repository's tag history. Before anything is uploaded the workflow requires that the tag parses, names a crate this workspace contains, matches that crate's Cargo.toml version, sits on an ancestor of main, and has a successful CI run for its exact commit. It then packages the crate before publishing it, so a missing README or a stale path dependency fails while the version is still recoverable. Authentication is crates.io trusted publishing over OIDC. There is no long-lived token in the repository's secrets; the job mints a short-lived one per run. The crates.io side must be configured once per crate before its first release through here, which the workflow header spells out. One crate per tag rather than one tag for the workspace: a dependent cannot be packaged until the version it requires is on the index, so ordering is real and making it explicit beats a job that leaves a release half-published. A tag is written by a person, so it is validated against a shape before any use and reaches the shell only as an environment variable, never interpolated into a command. Closes #16 --- .github/workflows/release.yml | 186 ++++++++++++++++++++++++++++++++++ README.md | 37 +++++++ 2 files changed, 223 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0779bfc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,186 @@ +# Tag-driven publishing to crates.io. +# +# What this exists to prevent: during the assessment behind issue #16, local +# main was seven commits behind the crates.io state. Versions 0.5.0, 0.4.0, and +# 0.2.0 had been published from a workstation while the tree still said 0.4.0, +# 0.3.0, and 0.1.1. Nothing tied a published version to a commit anyone could +# look at. +# +# So publishing happens here and only here, and every check below exists to +# make one guarantee: a version on crates.io corresponds to a tag in this +# repository, on main, whose CI was green. +# +# # Releasing +# +# One crate per tag, named `-v` — the format already in this +# repository's tag history: +# +# git tag -s agent-uri-v0.6.0 -m 'agent-uri 0.6.0' +# git push origin agent-uri-v0.6.0 +# +# For a release that spans crates, push the tags in dependency order and let +# each land before the next: agent-uri, then agent-uri-attestation, then +# agent-uri-dht and agent-uri-attestation-wellknown, then agent-uri-dht-libp2p +# and agent-uri-cli. A dependent cannot even be packaged until the version it +# requires is on the index, so pushing them all at once fails the later ones. +# +# # Authentication +# +# Trusted publishing: crates.io is configured to trust this repository and this +# workflow, and mints a short-lived token per run. There is no long-lived +# credential in the repository's secrets. +# +# Each crate must be registered before its first release through here. On +# crates.io, under the crate's Settings -> Trusted Publishing, add a publisher +# with: +# +# Repository owner Govcraft +# Repository name agent-uri-rs +# Workflow filename release.yml +# Environment crates-io +# +# for each of the six publishable crates: agent-uri, agent-uri-attestation, +# agent-uri-dht, agent-uri-cli, agent-uri-attestation-wellknown, and +# agent-uri-dht-libp2p. (agent-uri-eval is `publish = false` and needs +# nothing.) Until a crate is registered, its tag will pass every gate below and +# be refused at the publish step. +# +# Two of those six — agent-uri-attestation-wellknown and agent-uri-dht-libp2p — +# have never been published, and crates.io has no trusted-publisher settings +# for a crate that does not exist yet. Each needs one manual `cargo publish` to +# claim the name; every release after that can come from here. +# +# The `crates-io` GitHub environment is also where a required reviewer can be +# added, if a release should need a second pair of eyes. +name: Release + +on: + push: + tags: + - '*-v*' + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + publish: + name: publish ${{ github.ref_name }} + runs-on: ubuntu-latest + environment: crates-io + permissions: + contents: read + # Required to mint the OIDC token trusted publishing exchanges. + id-token: write + steps: + # Full history: the "is this commit on main" check needs main's ancestry, + # and a tag push checks out only the tagged commit by default. + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # The tag is the input, and a tag is written by a person. Everything + # derived from it is validated before use, and it is never interpolated + # into a shell command — it arrives as an environment variable. + - name: Parse the tag + id: tag + env: + TAG: ${{ github.ref_name }} + run: | + if ! printf '%s' "$TAG" | grep -qE '^[a-z][a-z0-9-]*-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::tag '$TAG' is not -v" >&2 + exit 1 + fi + crate=${TAG%-v*} + version=${TAG##*-v} + echo "crate=$crate" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + # A tag naming a crate this workspace does not contain would otherwise + # fail later and more confusingly. + - name: Check the crate is in this workspace + env: + CRATE: ${{ steps.tag.outputs.crate }} + run: | + if [ ! -f "$CRATE/Cargo.toml" ]; then + echo "::error::no crate '$CRATE' in this workspace" >&2 + exit 1 + fi + + # The tag says one version and the manifest says another is exactly how + # the repository and crates.io drifted apart in the first place. + - name: Check the tag matches the manifest + env: + CRATE: ${{ steps.tag.outputs.crate }} + VERSION: ${{ steps.tag.outputs.version }} + run: | + manifest=$( + grep -m1 -oE '^version = "[^"]+"' "$CRATE/Cargo.toml" \ + | sed 's/^version = "//; s/"$//' + ) + if [ "$manifest" != "$VERSION" ]; then + echo "::error::tag says $VERSION, $CRATE/Cargo.toml says ${manifest:-nothing}" >&2 + exit 1 + fi + + # A tag can be pushed from any commit, including one that was never + # reviewed or merged. Publishing from it would put code on crates.io that + # is not in the branch anyone reads. + - name: Check the tagged commit is on main + run: | + git fetch --no-tags origin main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::$GITHUB_SHA is not an ancestor of main" >&2 + exit 1 + fi + + # Green CI on this exact commit, not on something like it. Without this, + # a tag on a commit whose tests failed still publishes. + - name: Check CI passed for this commit + env: + GH_TOKEN: ${{ github.token }} + run: | + # -X GET is required: `gh api` switches to POST as soon as a field is + # given, and this endpoint has no POST. + conclusion=$( + gh api -X GET "repos/$GITHUB_REPOSITORY/actions/runs" \ + -f head_sha="$GITHUB_SHA" \ + -f event=push \ + --jq '[.workflow_runs[] | select(.name == "CI")] | first | .conclusion' + ) + if [ "$conclusion" != "success" ]; then + echo "::error::CI for $GITHUB_SHA concluded '${conclusion:-nothing}', not success" >&2 + exit 1 + fi + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + with: + key: release + + # Packages and builds the crate exactly as crates.io will receive it, + # before anything is uploaded. A missing README or a stale path + # dependency fails here rather than after the version is permanent. + - name: Package + env: + CRATE: ${{ steps.tag.outputs.crate }} + run: cargo package -p "$CRATE" + + # Pinned to an exact release: this action is the only thing standing + # between an OIDC token and a publish, and the upstream repository + # publishes no moving `v1` tag to float on. + - name: Authenticate to crates.io + uses: rust-lang/crates-io-auth-action@v1.0.5 + id: auth + + - name: Publish + env: + CRATE: ${{ steps.tag.outputs.crate }} + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + run: cargo publish -p "$CRATE" diff --git a/README.md b/README.md index 3690080..d0841db 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,43 @@ the checks behind the signature: it requires that anything accepted satisfies what the verifier promises of it, and that a token an honest issuer would mint is not refused. +## Releasing + +Publishing happens from CI and only from CI, so that a version on crates.io +always corresponds to a tag in this repository, on `main`, whose CI was green. + +One crate per tag, named `-v`: + +```bash +git tag -s agent-uri-v0.6.0 -m 'agent-uri 0.6.0' +git push origin agent-uri-v0.6.0 +``` + +The tag push runs [`.github/workflows/release.yml`](.github/workflows/release.yml), +which refuses to publish unless the tag parses, names a crate in this +workspace, matches that crate's `Cargo.toml` version, sits on an ancestor of +`main`, and has a successful CI run for its exact commit. Authentication is +crates.io trusted publishing over OIDC; there is no long-lived token in the +repository's secrets. + +For a release spanning several crates, push the tags in dependency order and +let each land before the next — a dependent cannot be packaged until the +version it requires is on the index: + +```text +agent-uri + └─ agent-uri-attestation + ├─ agent-uri-dht ─── agent-uri-dht-libp2p + ├─ agent-uri-attestation-wellknown + └─ agent-uri-cli +``` + +All six are publishable; `agent-uri-eval` is `publish = false`. Each needs a +trusted publisher configured on crates.io once, as described in the workflow +header — and `agent-uri-attestation-wellknown` and `agent-uri-dht-libp2p` need +one manual `cargo publish` first, because crates.io has no settings page for a +crate that does not exist yet. + ## Paper This implementation is based on the research paper: