From b584975a38f9786939b9b57f203b30e721bc5708 Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:08:07 +0900 Subject: [PATCH 1/2] ci: release only from a signed tag and an approved deployment release-plz now only keeps a draft release pull request; it never tags and never publishes. The maintainer's signed `vX.Y.Z` tag starts release.yml, which verifies that the tag names a commit on main whose Cargo version it matches, and attests the release candidate. Publishing waits in the `crates-io` environment for the maintainer's approval, then takes a crates.io token through trusted publishing alone. It publishes only if `cargo package` makes the attested archive, and then requires crates.io to serve it. The GitHub release follows, as release-finalize.yml did. 0.1.0 went out with a bootstrap token, so `crates-io-auth-mode` and its fallback go. The `release` environment now serves only the release pull request, from main. --- .github/workflows/release-finalize.yml | 97 --------------- .github/workflows/release-plz.yml | 96 ++------------- .github/workflows/release.yml | 157 +++++++++++++++++++++++++ docs/releasing.md | 50 ++++---- release-plz.toml | 13 +- xtask/src/cli.rs | 5 - xtask/src/tasks.rs | 25 ++-- 7 files changed, 211 insertions(+), 232 deletions(-) delete mode 100644 .github/workflows/release-finalize.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml deleted file mode 100644 index 4f2414c..0000000 --- a/.github/workflows/release-finalize.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: Finalize release - -# Called by release-plz.yml after it publishes the crate and creates the tag. -# The GitHub release is created here so the crate, SBOM, checksums, and attestations are attached before it is published. -"on": - workflow_call: - inputs: - tag: - description: Exact release tag created by release-plz - required: true - type: string - version: - description: Exact crate version released by release-plz - required: true - type: string - -permissions: {} - -concurrency: - group: release-finalize-${{ inputs.tag }} - cancel-in-progress: false - -jobs: - finalize: - name: Attest and publish the release - runs-on: windows-latest - timeout-minutes: 45 - permissions: - contents: write - id-token: write - attestations: write - artifact-metadata: write - env: - RELEASE_TAG: ${{ inputs.tag }} - RELEASE_VERSION: ${{ inputs.version }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.tag }} - fetch-depth: 0 - persist-credentials: false - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 - with: - toolchain: stable - - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2.85.5 - with: - tool: cargo-cyclonedx@0.5.9 - - run: python -m pip install "reuse[charset-normalizer]==6.2.0" - - name: Verify tag, commit, and Cargo version - run: cargo xtask verify-release-tag "$env:RELEASE_TAG" - - name: Build and verify release candidate - id: candidate - run: cargo xtask release-candidate --github-output - - # The attestations cover this rebuilt archive, so it must equal the one crates.io serves. - # `cargo package` is deterministic for a source tree; a mismatch stops the release. - - name: Require the built archive to match the published crate - shell: pwsh - run: | - $crate = Get-ChildItem target/release-candidate/*.crate | - Select-Object -First 1 - if ($null -eq $crate) { throw 'release candidate has no .crate archive' } - $built = (Get-FileHash $crate.FullName -Algorithm SHA256).Hash.ToLowerInvariant() - $uri = "https://crates.io/api/v1/crates/windows-spawn/$env:RELEASE_VERSION/download" - $probe = Join-Path $env:RUNNER_TEMP 'published.crate' - Invoke-WebRequest -Uri $uri -OutFile $probe -TimeoutSec 60 ` - -Headers @{ 'User-Agent' = 'windows-spawn-release-finalize' } - $published = (Get-FileHash $probe -Algorithm SHA256).Hash.ToLowerInvariant() - if ($built -ne $published) { - throw "built archive $built does not match the published crate $published" - } - "built and published archives match: $built" | Write-Output - - - name: Attest SLSA v1 provenance - uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4 - with: - subject-path: | - target/release-candidate/*.crate - target/release-candidate/*.cdx.json - target/release-candidate/*.reuse.spdx - target/release-candidate/SHA256SUMS - - - name: Attest CycloneDX SBOM - uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4 - with: - subject-path: target/release-candidate/*.crate - sbom-path: ${{ steps.candidate.outputs.sbom }} - - - name: Create draft GitHub Release - env: - GH_TOKEN: ${{ github.token }} - run: cargo xtask draft-release "$env:RELEASE_TAG" --github-output - - - name: Publish GitHub Release - env: - GH_TOKEN: ${{ github.token }} - run: gh release edit $env:RELEASE_TAG --draft=false diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 3ab57f9..3ceb054 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -3,115 +3,33 @@ # # SPDX-License-Identifier: MIT OR Apache-2.0 -# The upstream release-plz workflow, with documented deviations: +# Proposes the next release as a draft pull request, and never publishes; release.yml does, from a signed tag. # -# * A GitHub App token, because GITHUB_TOKEN cannot trigger CI on the release pull request. -# * `environment: release` on both jobs, which holds the App and crates.io credentials. -# * Windows runners, because `cargo publish` verifies by building, and the `cfg(windows)` windows-sys dependency does not build on Linux. -name: Release-plz +# * A GitHub App token, because a pull request opened with GITHUB_TOKEN starts no CI. +# * `environment: release` holds the App's credentials, and deploys from main only. +# * A Windows runner, because `semver_check` builds the crate, and its `cfg(windows)` dependencies do not build on Linux. +name: Release PR "on": push: branches: [main] - # Manual dispatch retries a failed release; it still publishes only when the manifest version is ahead of the registry. workflow_dispatch: permissions: {} concurrency: - group: release-plz-${{ github.ref }} + group: release-plz-pr-${{ github.ref }} cancel-in-progress: false jobs: - release: - name: Release-plz release - if: github.repository == 'P4suta/windows-spawn' - runs-on: windows-2022 - timeout-minutes: 45 - environment: - name: release - permissions: - contents: write - id-token: write - outputs: - released: ${{ steps.run.outputs.releases_created }} - tag: ${{ steps.released.outputs.tag }} - version: ${{ steps.released.outputs.version }} - steps: - - name: Mint the release GitHub App token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.RELEASE_PLZ_APP_CLIENT_ID }} - private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} - permission-contents: write - permission-pull-requests: read - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 - with: - toolchain: stable - # The bootstrap token published 0.1.0; once a trusted publisher is registered, delete it and OpenID Connect takes over. - - name: Select the crates.io credential - id: auth-mode - env: - CRATES_IO_BOOTSTRAP_TOKEN: ${{ secrets.CRATES_IO_BOOTSTRAP_TOKEN }} - run: cargo xtask crates-io-auth-mode --github-output - - name: Authenticate with crates.io trusted publishing - id: crates-auth - if: steps.auth-mode.outputs.bootstrap == 'false' - uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 - - name: Run release-plz - id: run - uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 # v0.5.131 - with: - command: release - version: 0.3.160 - env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - CARGO_REGISTRY_TOKEN: >- - ${{ steps.crates-auth.outputs.token - || secrets.CRATES_IO_BOOTSTRAP_TOKEN }} - - name: Read the released tag and version - id: released - if: steps.run.outputs.releases_created == 'true' - shell: bash - env: - RELEASES: ${{ steps.run.outputs.releases }} - run: | - set -euo pipefail - jq -r '.[0] | "tag=\(.tag)", "version=\(.version)"' <<< "$RELEASES" >> "$GITHUB_OUTPUT" - - # Creates the GitHub release with the crate, SBOM, checksums, and attestations attached. - finalize: - name: Attest and publish the release - needs: release - if: needs.release.outputs.released == 'true' - permissions: - artifact-metadata: write - attestations: write - contents: write - id-token: write - uses: ./.github/workflows/release-finalize.yml - with: - tag: ${{ needs.release.outputs.tag }} - version: ${{ needs.release.outputs.version }} - release-pr: name: Release-plz PR - # Runs after `release` so no release pull request opens for a version this run publishes. - needs: release - if: always() && github.repository == 'P4suta/windows-spawn' + if: github.repository == 'P4suta/windows-spawn' runs-on: windows-2022 timeout-minutes: 30 environment: name: release permissions: {} - concurrency: - group: release-plz-pr-${{ github.ref }} - cancel-in-progress: false steps: - name: Mint the release GitHub App token id: app-token diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e66d33a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,157 @@ +--- +# SPDX-FileCopyrightText: 2026 Yasunobu Sakashita +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# Publishes the version a maintainer's signed `vX.Y.Z` tag names. +# +# * The tag ruleset admits only the maintainer's signed tags, so no workflow and no token of this repository can start a release. +# * `publish` deploys to the `crates-io` environment, which waits for the maintainer's approval. +# crates.io's trusted publisher admits only that environment of this workflow, so nothing else can mint a publish token. +# * Windows runners, because `cargo publish` verifies by building, and the `cfg(windows)` dependencies do not build on Linux. +name: Release + +"on": + push: + tags: ["v[0-9]+.[0-9]+.[0-9]+"] + +permissions: {} + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + candidate: + name: Verify the tag and attest the release candidate + if: github.repository == 'P4suta/windows-spawn' + runs-on: windows-latest + timeout-minutes: 45 + permissions: + artifact-metadata: write + attestations: write + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2.85.5 + with: + tool: cargo-cyclonedx@0.5.9 + - run: python -m pip install "reuse[charset-normalizer]==6.2.0" + - name: Verify the tag, its commit on main, and the Cargo version + run: cargo xtask verify-release-tag "$env:GITHUB_REF_NAME" + - name: Build and verify the release candidate + id: candidate + run: cargo xtask release-candidate --github-output + - name: Attest SLSA v1 provenance + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4 + with: + subject-path: | + target/release-candidate/*.crate + target/release-candidate/*.cdx.json + target/release-candidate/*.reuse.spdx + target/release-candidate/SHA256SUMS + - name: Attest CycloneDX SBOM + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4 + with: + subject-path: target/release-candidate/*.crate + sbom-path: ${{ steps.candidate.outputs.sbom }} + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-candidate + path: target/release-candidate/ + if-no-files-found: error + + publish: + name: Publish to crates.io + needs: candidate + runs-on: windows-latest + timeout-minutes: 30 + environment: + name: crates-io + url: https://crates.io/crates/windows-spawn + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-candidate + path: ${{ runner.temp }}/release-candidate + # The attestations cover the candidate, so the archive `cargo publish` uploads must be that very file. + # `cargo package` is deterministic for a source tree; a mismatch stops the release before anything is published. + - name: Require the archive to publish to be the attested candidate + shell: pwsh + run: | + $version = $env:GITHUB_REF_NAME.Substring(1) + $name = "windows-spawn-$version.crate" + cargo package --locked + if ($LASTEXITCODE -ne 0) { throw "cargo package exited $LASTEXITCODE" } + $packaged = (Get-FileHash "target/package/$name" -Algorithm SHA256).Hash + $candidate = (Get-FileHash "$env:RUNNER_TEMP/release-candidate/$name" -Algorithm SHA256).Hash + if ($packaged -ne $candidate) { + throw "packaged $name is $packaged, but the attested candidate is $candidate" + } + "packaged archive matches the attested candidate: $packaged" | Write-Output + - name: Authenticate with crates.io trusted publishing + id: crates-auth + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 + - name: Publish the crate + run: cargo publish --locked + env: + CARGO_REGISTRY_TOKEN: ${{ steps.crates-auth.outputs.token }} + - name: Require crates.io to serve the attested candidate + shell: pwsh + run: | + $version = $env:GITHUB_REF_NAME.Substring(1) + $name = "windows-spawn-$version.crate" + $candidate = (Get-FileHash "$env:RUNNER_TEMP/release-candidate/$name" -Algorithm SHA256).Hash + $uri = "https://crates.io/api/v1/crates/windows-spawn/$version/download" + $probe = Join-Path $env:RUNNER_TEMP 'published.crate' + Invoke-WebRequest -Uri $uri -OutFile $probe -TimeoutSec 60 ` + -Headers @{ 'User-Agent' = 'windows-spawn-release' } + $published = (Get-FileHash $probe -Algorithm SHA256).Hash + if ($published -ne $candidate) { + throw "crates.io serves $published, but the attested candidate is $candidate" + } + "crates.io serves the attested candidate: $published" | Write-Output + + github-release: + name: Publish the GitHub release + needs: publish + runs-on: windows-latest + timeout-minutes: 30 + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-candidate + path: target/release-candidate + # Assets go onto a draft first, because a published release cannot gain them without being seen half-built. + - name: Create the draft GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: cargo xtask draft-release "$env:GITHUB_REF_NAME" --github-output + - name: Publish the GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "$env:GITHUB_REF_NAME" --draft=false --latest diff --git a/docs/releasing.md b/docs/releasing.md index 43f6ccb..190aa14 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,6 +1,6 @@ # Release procedure -Pushing a branch or tag does not release. +Only the maintainer's signed tag starts a release, and nothing is published until the maintainer approves its deployment. ## Local candidate @@ -20,32 +20,38 @@ It writes `target/release-candidate/SHA256SUMS`. ## Repository setup -Create a GitHub environment named `release` with the repository's release branch restrictions. -It holds the `RELEASE_PLZ_APP_CLIENT_ID` variable and the `RELEASE_PLZ_APP_PRIVATE_KEY` secret of the `p4suta-release-plz` App, which needs **Contents** and **Pull requests** read/write. +- The `release` environment deploys from `main` only. + It holds the `RELEASE_PLZ_APP_CLIENT_ID` variable and the `RELEASE_PLZ_APP_PRIVATE_KEY` secret of the `p4suta-release-plz` App, which needs **Contents** and **Pull requests** read/write. + The App token is needed because a pull request opened with `GITHUB_TOKEN` starts no CI. +- The `crates-io` environment deploys from `v*` tags only, and requires the maintainer's review. +- The crate's trusted publisher on crates.io is this repository, `release.yml`, and the `crates-io` environment, and the crate accepts trusted publishing only. +- The `v*` tag ruleset lets only the maintainer create a tag, requires it to be signed, and forbids moving or deleting one. -The App token is needed because `GITHUB_TOKEN` cannot trigger workflows, so CI would not run on a release pull request. Every action is pinned to a full commit SHA. ## Release flow -`release-plz.yml` runs on every push to `main`. -It publishes only when the manifest version is ahead of the registry, so **merging a reviewed release pull request is what authorizes a publish.** Let release-plz bump the version; a manual bump still publishes but skips that review. - -release-plz publishes the crate and creates `vX.Y.Z`. -`git_release_enable = false` leaves the GitHub release to `release-finalize.yml`, called by the same run, because the crate, SBOMs, checksums, and attestations must be attached before publication and draft releases fire no event. - -The finalizer verifies the tag, rebuilds the release candidate, and requires the rebuilt crate's SHA-256 to equal the one crates.io serves. -It then creates SLSA v1 provenance and CycloneDX SBOM attestations, uploads the crate, SBOMs, and checksums to a draft release, and publishes it. -A failure can leave a draft; remove it before re-running. - -## crates.io credentials - -A trusted publisher can be registered only for an existing crate, so the first publication needs a token: - -1. Store a short-lived crates.io token as the `CRATES_IO_BOOTSTRAP_TOKEN` secret of the `release` environment. -2. Release normally; `cargo xtask crates-io-auth-mode` reports the credential used. -3. Register this repository, `.github/workflows/release-plz.yml`, and the `release` environment as the crate's trusted publisher. -4. Delete the secret and revoke the token; later releases use OpenID Connect only. +1. `release-plz.yml` runs on every push to `main`, and keeps a draft pull request that bumps the version and updates `CHANGELOG.md`. + It never tags and never publishes. +2. Mark the pull request ready, review it, and merge it. +3. Tag the merge commit with a signed tag, and push it: + + ```powershell + git switch main + git pull --ff-only + git tag -s v0.2.0 -m v0.2.0 + git push origin v0.2.0 + ``` + +4. `release.yml` verifies that the tag names a commit on `main` whose Cargo version it matches. + It builds the release candidate, attests SLSA v1 provenance and the CycloneDX SBOM, and waits in the `crates-io` environment. +5. Approve the deployment. + The job takes a short-lived crates.io token through OpenID Connect, and publishes only if the archive `cargo package` makes is the attested candidate. + It then requires crates.io to serve that same archive. +6. The last job uploads the crate, SBOMs, and checksums to a draft GitHub release, and publishes it. + +Rejecting the deployment leaves the tag and the attestations, but publishes nothing. +A failure after publishing can leave a draft release; remove it before re-running the failed jobs. ## Verification diff --git a/release-plz.toml b/release-plz.toml index 4494d8b..7a88345 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -2,24 +2,23 @@ # # SPDX-License-Identifier: MIT OR Apache-2.0 +# release-plz only proposes the release pull request. +# The maintainer's signed tag starts release.yml, which publishes after an approved deployment. [workspace] allow_dirty = false changelog_update = true dependencies_update = false -# `release_always` keeps its default, true. -# With false, a manually bumped version could never be released: no release pull request is proposed for it, and only such a pull request releases. -git_tag_enable = true +git_tag_enable = false git_tag_name = "v{{ version }}" -# release-finalize.yml creates the GitHub release, after attaching the crate, SBOM, and attestations. git_release_enable = false -publish = true +publish = false +pr_draft = true +pr_name = "chore(release): v{{ version }}" semver_check = true [[package]] name = "xtask" release = false -# The workspace `publish = true` also applies to xtask, and release-plz rejects a mismatch with its `publish = false` manifest. -publish = false [changelog] protect_breaking_commits = true diff --git a/xtask/src/cli.rs b/xtask/src/cli.rs index 57b0c01..8843f10 100644 --- a/xtask/src/cli.rs +++ b/xtask/src/cli.rs @@ -43,9 +43,6 @@ pub(crate) enum Task { tag: String, github_output: bool, }, - CratesIoAuthMode { - github_output: bool, - }, Help, } @@ -79,8 +76,6 @@ pub(crate) fn parse(arguments: impl IntoIterator) -> Result parse_mutation(&rest), "draft-release" => parse_tag_and_github_output(&rest) .map(|(tag, github_output)| Task::DraftRelease { tag, github_output }), - "crates-io-auth-mode" => parse_single_flag(&rest, "--github-output") - .map(|github_output| Task::CratesIoAuthMode { github_output }), "help" | "-h" | "--help" => no_arguments(&rest, Task::Help), _ => Err(format!("unknown xtask command: {command}")), } diff --git a/xtask/src/tasks.rs b/xtask/src/tasks.rs index 90be162..d833b87 100644 --- a/xtask/src/tasks.rs +++ b/xtask/src/tasks.rs @@ -106,7 +106,6 @@ pub(crate) fn execute(task: Task) -> Result { Task::DraftRelease { tag, github_output } => { draft_release(&root, &tag, github_output)?; } - Task::CratesIoAuthMode { github_output } => crates_io_auth_mode(github_output)?, Task::Help => print_help(), } Ok(0) @@ -124,8 +123,7 @@ Repository tasks: cargo xtask release-candidate [--allow-dirty] [--github-output] cargo xtask verify-release-tag TAG cargo xtask mutation [-- rust-mutants run arguments] - cargo xtask draft-release TAG [--github-output] - cargo xtask crates-io-auth-mode [--github-output]" + cargo xtask draft-release TAG [--github-output]" ); } @@ -695,6 +693,18 @@ fn verify_release_tag(root: &Path, tag: &str) -> Result<()> { "tag {tag} does not resolve to checked-out commit {head_commit}" )); } + let mut on_main = Command::new("git"); + on_main.current_dir(root).args([ + "merge-base", + "--is-ancestor", + head_commit, + "refs/remotes/origin/main", + ]); + if !on_main.status()?.success() { + return fail(format!( + "tag {tag} names {head_commit}, which origin/main does not contain" + )); + } let package = root_package(root)?; if package.version != version { return fail(format!( @@ -743,15 +753,6 @@ fn draft_release(root: &Path, tag: &str, github_output: bool) -> Result<()> { Ok(()) } -fn crates_io_auth_mode(github_output: bool) -> Result<()> { - let bootstrap = env::var_os("CRATES_IO_BOOTSTRAP_TOKEN").is_some_and(|value| !value.is_empty()); - println!("bootstrap={bootstrap}"); - if github_output { - write_github_output_value("bootstrap", &bootstrap.to_string())?; - } - Ok(()) -} - fn write_github_output(key: &str, value: &Path) -> Result<()> { write_github_output_value(key, &value.to_string_lossy()) } From 09b5c5503a5972e37b0508c257a3b51b9224d204 Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:27:57 +0900 Subject: [PATCH 2/2] ci: refuse a release tag that is not annotated and verified as signed release.yml runs as the tagged commit has it, so this check is defence in depth: the creation rule and the approval remain the gates, and the approver now checks the run before approving. The tag protection is described as two rulesets, because a bypass actor skips every rule of its ruleset, signatures and the ban on moving tags included. actions/attest moves to v4.2.2, which `v4` now names. --- .github/workflows/release.yml | 24 +++++++++++++++++++++--- docs/releasing.md | 11 +++++++---- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e66d33a..067c114 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,8 @@ # Publishes the version a maintainer's signed `vX.Y.Z` tag names. # -# * The tag ruleset admits only the maintainer's signed tags, so no workflow and no token of this repository can start a release. +# * The tag rulesets let only the maintainer create a `v*` tag, and `candidate` refuses one that is not annotated and verified as signed. +# This file runs as the tagged commit has it, so those checks are defence in depth; the creation rule and the approval are the gates. # * `publish` deploys to the `crates-io` environment, which waits for the maintainer's approval. # crates.io's trusted publisher admits only that environment of this workflow, so nothing else can mint a publish token. # * Windows runners, because `cargo publish` verifies by building, and the `cfg(windows)` dependencies do not build on Linux. @@ -44,13 +45,30 @@ jobs: with: tool: cargo-cyclonedx@0.5.9 - run: python -m pip install "reuse[charset-normalizer]==6.2.0" + - name: Require an annotated tag whose signature GitHub verified + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + ref="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}")" + if [ "$(jq -r .object.type <<<"${ref}")" != tag ]; then + echo "::error::${TAG} is a lightweight tag; a release tag is annotated and signed" + exit 1 + fi + tag="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/$(jq -r .object.sha <<<"${ref}")")" + if [ "$(jq -r .verification.verified <<<"${tag}")" != true ]; then + echo "::error::GitHub did not verify the signature on ${TAG}: $(jq -r .verification.reason <<<"${tag}")" + exit 1 + fi - name: Verify the tag, its commit on main, and the Cargo version run: cargo xtask verify-release-tag "$env:GITHUB_REF_NAME" - name: Build and verify the release candidate id: candidate run: cargo xtask release-candidate --github-output - name: Attest SLSA v1 provenance - uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: subject-path: | target/release-candidate/*.crate @@ -58,7 +76,7 @@ jobs: target/release-candidate/*.reuse.spdx target/release-candidate/SHA256SUMS - name: Attest CycloneDX SBOM - uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: subject-path: target/release-candidate/*.crate sbom-path: ${{ steps.candidate.outputs.sbom }} diff --git a/docs/releasing.md b/docs/releasing.md index 190aa14..965eba8 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -23,9 +23,12 @@ It writes `target/release-candidate/SHA256SUMS`. - The `release` environment deploys from `main` only. It holds the `RELEASE_PLZ_APP_CLIENT_ID` variable and the `RELEASE_PLZ_APP_PRIVATE_KEY` secret of the `p4suta-release-plz` App, which needs **Contents** and **Pull requests** read/write. The App token is needed because a pull request opened with `GITHUB_TOKEN` starts no CI. -- The `crates-io` environment deploys from `v*` tags only, and requires the maintainer's review. +- The `crates-io` environment deploys from `v*` tags only, requires the maintainer's review, and lets no administrator bypass it. - The crate's trusted publisher on crates.io is this repository, `release.yml`, and the `crates-io` environment, and the crate accepts trusted publishing only. -- The `v*` tag ruleset lets only the maintainer create a tag, requires it to be signed, and forbids moving or deleting one. +- Two rulesets guard `v*` tags, because a bypass actor skips every rule of its ruleset. + One restricts creation, and only the maintainer bypasses it. + The other forbids moving or deleting a tag and requires signatures, and nobody bypasses it. +- `release.yml` also refuses a tag that is not annotated and verified as signed, but it runs as the tagged commit has it, so the creation rule and the approval are what guard a release. Every action is pinned to a full commit SHA. @@ -43,9 +46,9 @@ Every action is pinned to a full commit SHA. git push origin v0.2.0 ``` -4. `release.yml` verifies that the tag names a commit on `main` whose Cargo version it matches. +4. `release.yml` verifies that the tag is annotated and signed, and names a commit on `main` whose Cargo version it matches. It builds the release candidate, attests SLSA v1 provenance and the CycloneDX SBOM, and waits in the `crates-io` environment. -5. Approve the deployment. +5. Check that the run is for the tag you pushed, on the commit `main` holds, and that `candidate` passed; then approve the deployment. The job takes a short-lived crates.io token through OpenID Connect, and publishes only if the archive `cargo package` makes is the attested candidate. It then requires crates.io to serve that same archive. 6. The last job uploads the crate, SBOMs, and checksums to a draft GitHub release, and publishes it.