From 12560e3e3163392c3d954af66903f5be823c1e39 Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 15 Oct 2026 23:36:29 +0200 Subject: [PATCH] ci(release): publish a tag only from green CI, and re-drive by tag input The release workflow no longer lints or tests at the tag. It requires the tagged commit's ci.yml push run to have succeeded (waiting up to an hour), checks the tag against the crate version, and publishes through trusted publishing, skipping a version already on crates.io. A manual run takes a tag input and checks that tag out. ci.yml's MSRV job now runs fmt and clippy as well. --- .github/workflows/ci.yml | 9 ++ .github/workflows/release.yml | 221 +++++++++++++++------------------- 2 files changed, 105 insertions(+), 125 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6240633..fabd062 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -175,12 +175,21 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0 with: toolchain: 1.92.0 + components: rustfmt, clippy rustflags: "" cache-shared-key: ci-msrv - name: Check run: cargo check --all-targets --all-features + # The MSRV toolchain lints differently from stable; lint here too so a + # pull request is held to every toolchain the crate supports. + - name: Format check (MSRV) + run: cargo fmt --all -- --check + + - name: Clippy (MSRV) + run: cargo clippy --all-targets --all-features -- -D warnings + - name: Test run: cargo test --all-features diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f04aacd..01369ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,152 +1,126 @@ -name: Release Gate - +name: Release + +# Publishes guard-agent-rs to crates.io for a release tag. Nothing is linted +# or tested here: the tagged commit must already have passed the push +# workflows listed in REQUIRED_WORKFLOWS (ci.yml runs fmt and clippy on +# stable and on the MSRV, the tests with Redis, and coverage). A release can +# therefore never fail on a check its pull request did not run. +# +# Re-drive: run this workflow by hand (Actions > Release > Run workflow) with +# the tag, for example v3.2.4. It checks out that tag, so a fixed workflow on +# master can publish an existing tag. A version already on crates.io is +# skipped. guard-core-rs at the required version must be on crates.io first. on: - push: - tags: - - 'v*' release: types: [created] workflow_dispatch: + inputs: + tag: + description: 'Release tag to publish, for example v3.2.4' + required: true permissions: {} +concurrency: + group: release-${{ inputs.tag || github.event.release.tag_name }} + cancel-in-progress: false + env: - CARGO_INCREMENTAL: 0 - CARGO_NET_RETRY: 10 CARGO_TERM_COLOR: always + REQUIRED_WORKFLOWS: >- + .github/workflows/ci.yml jobs: - test: - name: Test at tag (${{ matrix.rust }}) - runs-on: ubuntu-latest - permissions: - contents: read - strategy: - fail-fast: false - matrix: - rust: ['stable', '1.92.0'] - - services: - redis: - image: redis:7-alpine - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - name: Checkout tag - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - # The workspace's examples/basic_usage carries the guard-core-rs path - # dependency (the bridge feature's sibling), so every job that loads - # the workspace metadata checks the sibling out first (the ci.yml - # shape). The packaged crate itself ships the registry dependency: - # packaging strips the path and keeps the version pin. - - name: Checkout guard-core-rs (path dependency) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: Guard-Core/guard-core-rs - ref: master - path: core-checkout - persist-credentials: false - - - name: Place guard-core-rs at the path dependency location - run: mv core-checkout ../guard-core-rs - - - - name: Setup Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0 - with: - toolchain: ${{ matrix.rust }} - components: rustfmt, clippy - rustflags: "" - cache-shared-key: release-${{ matrix.rust }} - - - name: Format check - run: cargo fmt --all -- --check - - - name: Clippy - run: cargo clippy --all-targets --all-features -- -D warnings - - - name: Test (default features) - run: cargo test - - - name: Test (all features, including Redis persistence) - run: cargo test --all-features -- --include-ignored - - tag-consistency: - name: Tag matches crate version - needs: [test] + verify: + name: Tagged commit passed CI runs-on: ubuntu-latest permissions: contents: read + actions: read + outputs: + tag: ${{ steps.tag.outputs.tag }} steps: - - name: Checkout tag - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false + - name: Resolve the tag + id: tag + env: + INPUT_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + tag="${INPUT_TAG:-$RELEASE_TAG}" + if ! printf '%s' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::tag '$tag' is not of the form v1.2.3" + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" - # The workspace's examples/basic_usage carries the guard-core-rs path - # dependency (the bridge feature's sibling), so every job that loads - # the workspace metadata checks the sibling out first (the ci.yml - # shape). The packaged crate itself ships the registry dependency: - # packaging strips the path and keeps the version pin. - - name: Checkout guard-core-rs (path dependency) + - name: Checkout the tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - repository: Guard-Core/guard-core-rs - ref: master - path: core-checkout + ref: refs/tags/${{ steps.tag.outputs.tag }} persist-credentials: false - - name: Place guard-core-rs at the path dependency location - run: mv core-checkout ../guard-core-rs - - - - name: Setup Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0 - with: - rustflags: "" - - - name: Verify tag equals the crate version + - name: Verify the tag equals the crate version + env: + TAG: ${{ steps.tag.outputs.tag }} run: | - tag="${GITHUB_REF_NAME#v}" - version=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "guard-agent-rs") | .version') - if [ "$tag" != "$version" ]; then - echo "tag v$tag does not match crate version $version" + version=$(sed -n 's/^version *= *"\(.*\)"/\1/p' Cargo.toml | head -1) + if [ "${TAG#v}" != "$version" ]; then + echo "::error::tag $TAG does not match guard-agent-rs version $version" exit 1 fi - echo "ok: tag v$tag matches guard-agent-rs version $version" + echo "ok: $TAG matches guard-agent-rs $version" + + # The newest push run of each required workflow on the tagged commit + # must have succeeded. A release created right after a merge waits for + # them (up to an hour) instead of failing. + - name: Require green CI on the tagged commit + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + sha=$(git rev-parse HEAD) + for _ in $(seq 1 60); do + runs=$(gh api "repos/$REPO/actions/runs?head_sha=$sha&event=push&per_page=100" \ + --jq '[.workflow_runs | group_by(.path)[] | max_by(.run_number) | {path, status, conclusion}]') + waiting=0 + for wf in $REQUIRED_WORKFLOWS; do + run=$(jq -c --arg p "$wf" '.[] | select(.path == $p)' <<< "$runs") + if [ -z "$run" ]; then + echo "$wf: no push run on $sha yet"; waiting=1; continue + fi + status=$(jq -r .status <<< "$run"); conclusion=$(jq -r .conclusion <<< "$run") + if [ "$status" != "completed" ]; then + echo "$wf: $status"; waiting=1 + elif [ "$conclusion" != "success" ]; then + echo "::error::$wf concluded $conclusion on $sha; fix master and release a new tag" + exit 1 + else + echo "ok: $wf success on $sha" + fi + done + [ "$waiting" = "0" ] && exit 0 + sleep 60 + done + echo "::error::required workflows did not finish on $sha within an hour" + exit 1 publish: name: Publish to crates.io - needs: tag-consistency - # Trusted publishing (crates.io enforces it for this crate): the job - # exchanges the Actions OIDC token via id-token: write. workflow_dispatch - # is allowed so a publish can be re-driven after a partial failure - # (master's crate version must match the intended release). - if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + needs: verify runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - - name: Checkout release tag + - name: Checkout the tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: refs/tags/${{ needs.verify.outputs.tag }} persist-credentials: false # The workspace's examples/basic_usage carries the guard-core-rs path - # dependency (the bridge feature's sibling), so every job that loads - # the workspace metadata checks the sibling out first (the ci.yml - # shape). The packaged crate itself ships the registry dependency: - # packaging strips the path and keeps the version pin. + # dependency, so loading the workspace needs the sibling checkout. The + # packaged crate itself ships the registry dependency. - name: Checkout guard-core-rs (path dependency) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -158,8 +132,7 @@ jobs: - name: Place guard-core-rs at the path dependency location run: mv core-checkout ../guard-core-rs - - - name: Setup Rust toolchain + - name: Install the Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0 with: rustflags: "" @@ -170,26 +143,24 @@ jobs: uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 id: auth - # Retried up to 3 times (crates.io transient 5xx). A version already - # on crates.io (the partial-failure rerun) skips via the registry - # check, so a re-drive never blocks on the identical-version rejection. - name: Publish guard-agent-rs env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + TAG: ${{ needs.verify.outputs.tag }} run: | - set -o pipefail - tag="${GITHUB_REF_NAME#v}" - published=$(curl -fsSL -A "guard-agent-rs release (https://github.com/Guard-Core/guard-agent-rs)" "https://crates.io/api/v1/crates/guard-agent-rs" | jq -r .crate.max_version || true) - if [ "$published" = "$tag" ]; then - echo "guard-agent-rs $tag is already on crates.io; skipping" + version="${TAG#v}" + code=$(curl -s -o /dev/null -w '%{http_code}' -A "guard-agent-rs release (https://github.com/Guard-Core/guard-agent-rs)" \ + "https://crates.io/api/v1/crates/guard-agent-rs/$version") + if [ "$code" = "200" ]; then + echo "guard-agent-rs $version is already on crates.io; skipping" exit 0 fi for attempt in 1 2 3; do - if cargo publish; then + if cargo publish -p guard-agent-rs --locked; then exit 0 fi echo "attempt $attempt failed; retrying in 30s" sleep 30 done - echo "guard-agent-rs publish failed after 3 attempts" + echo "::error::guard-agent-rs publish failed after 3 attempts" exit 1