From ec2d0434cbaa3285922264cb66216a01a612f2fe Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:33:30 +0900 Subject: [PATCH] chore: move releases onto release-plz like the sibling crates This was the last repository still driving releases by hand: tag the commit, dispatch Release with the tag and a publish checkbox, approve. It worked, but it left the version bump, the changelog entry, and the tag as three manual steps that nothing checked against each other, and it was the only one of the four crates not on the pattern. release-plz.yml now runs on every push to main and publishes only when the manifest version is ahead of the registry, so merging a reviewed release pull request is what authorises a publish. Two deviations from the upstream template stay, both documented upstream: a GitHub App token, because the default GITHUB_TOKEN cannot trigger the CI a release pull request needs, and windows runners, because cargo publish verifies the tarball by building it and this crate does not build on Linux. release.yml becomes release-finalize.yml, called from the same run. git_release_enable = false leaves the GitHub release to it, so the existing draft-release xtask still applies the crate, both SBOMs, and checksums before anything is published. It cannot be a release-event handler: GitHub fires no release events for drafts. The finalizer gains a check the manual flow could not make. It previously attested an archive it had just built, with no evidence that archive was what the registry served. It now requires the rebuilt archive's SHA-256 to equal the published crate before attesting, so the provenance is bound to the artifact consumers actually download. crates-io-auth-mode is kept and reused rather than reimplemented in the workflow: it is already tested, and duplicating the check in YAML would have left the xtask dead. Co-Authored-By: Claude Fable 5 --- .github/workflows/release-finalize.yml | 101 +++++++++++++++++ .github/workflows/release-plz.yml | 151 +++++++++++++++++++++++++ .github/workflows/release.yml | 121 -------------------- docs/releasing.md | 76 ++++++++----- release-plz.toml | 29 +++++ 5 files changed, 326 insertions(+), 152 deletions(-) create mode 100644 .github/workflows/release-finalize.yml create mode 100644 .github/workflows/release-plz.yml delete mode 100644 .github/workflows/release.yml create mode 100644 release-plz.toml diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml new file mode 100644 index 0000000..5cf21b0 --- /dev/null +++ b/.github/workflows/release-finalize.yml @@ -0,0 +1,101 @@ +name: Finalize release + +# Called by release-plz.yml once release-plz has published the crate and created +# the tag. `git_release_enable = false` leaves the GitHub release to this +# workflow, because the crate, SBOM, checksums, and attestations have to be +# 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 below cover the archive built here, so it has to be + # the archive crates.io actually serves. `cargo package` is deterministic + # for a given source tree, and release-plz published from this same tag, + # so a mismatch means something diverged and the release must not proceed. + - 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 new file mode 100644 index 0000000..5da0fba --- /dev/null +++ b/.github/workflows/release-plz.yml @@ -0,0 +1,151 @@ +--- +# SPDX-FileCopyrightText: 2026 Yasunobu Sakashita +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# The upstream-recommended release-plz workflow. Two deviations, both of which +# release-plz documents: +# +# * A GitHub App token instead of GITHUB_TOKEN. The default token cannot +# trigger other workflows, so CI would never run on a release pull request. +# * `environment: release` on both jobs. The App credentials are scoped to it, +# and it is where the crates.io credential lives. +# +# Windows, not the ubuntu-latest of the upstream template: `cargo publish` +# verifies the tarball by building it, and this crate does not build on Linux +# because its windows-sys dependency sits behind `cfg(windows)`. +name: Release-plz + +"on": + push: + branches: [main] + +permissions: {} + +concurrency: + group: release-plz-${{ 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 + # A trusted publisher can only be registered against a crate that already + # exists. The bootstrap token covered 0.1.0; once a trusted publisher is + # registered for this workflow the secret can be deleted and the OpenID + # Connect exchange 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" + + # release-plz publishes the crate and creates the tag; the GitHub release is + # built here so the crate, SBOM, checksums, and attestations are attached + # before it is published. + 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 + # Ordered after `release` so a release pull request is never opened for a + # version that is being published in the same run. + needs: release + if: always() && 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 + 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: write + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + # `semver_check = true` in release-plz.toml needs this on PATH. + - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2.85.5 + with: + tool: cargo-semver-checks@0.45.0 + - name: Run release-plz + uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 # v0.5.131 + with: + command: release-pr + version: 0.3.160 + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 8624a3d..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,121 +0,0 @@ -name: Release - -"on": - workflow_dispatch: - inputs: - tag: - description: Existing v-prefixed SemVer tag to release - required: true - type: string - publish_crates_io: - description: Publish the matching package to crates.io - required: true - default: false - type: boolean - -permissions: - contents: read - -concurrency: - group: release - cancel-in-progress: false - -jobs: - validate: - name: Validate release tag - runs-on: windows-latest - timeout-minutes: 10 - 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 - - name: Verify existing tag, commit, and Cargo version - env: - RELEASE_TAG: ${{ inputs.tag }} - run: cargo xtask verify-release-tag "$env:RELEASE_TAG" - - release: - name: Approved release - needs: validate - runs-on: windows-latest - timeout-minutes: 45 - environment: - name: release - permissions: - contents: write - id-token: write - attestations: write - artifact-metadata: write - env: - RELEASE_TAG: ${{ inputs.tag }} - 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: Build and verify 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 }} - - - name: Create draft GitHub Release - id: github-release - env: - GH_TOKEN: ${{ github.token }} - run: cargo xtask draft-release "$env:RELEASE_TAG" --github-output - - - name: Select crates.io authentication mode - id: crates-mode - if: ${{ inputs.publish_crates_io }} - 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: ${{ inputs.publish_crates_io && steps.crates-mode.outputs.bootstrap == 'false' }} - uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 - - - name: Bootstrap first crates.io publication - if: ${{ inputs.publish_crates_io && steps.crates-mode.outputs.bootstrap == 'true' }} - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_BOOTSTRAP_TOKEN }} - run: cargo publish --locked - - - name: Publish to crates.io with trusted publishing - if: ${{ inputs.publish_crates_io && steps.crates-mode.outputs.bootstrap == 'false' }} - env: - CARGO_REGISTRY_TOKEN: ${{ steps.crates-auth.outputs.token }} - run: cargo publish --locked - - - name: Publish GitHub Release - env: - GH_TOKEN: ${{ github.token }} - run: gh release edit $env:RELEASE_TAG --draft=false diff --git a/docs/releasing.md b/docs/releasing.md index 4128770..3fa57b4 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -24,39 +24,53 @@ SBOM, validates both SBOMs, and writes `target/release-candidate/SHA256SUMS`. ## Repository setup -Create a GitHub environment named `release` with at least one required reviewer -before adding a publishing credential. Apply the repository's release branch -restrictions. - -The workflow needs the repository's default `GITHUB_TOKEN` permissions only; -its job requests `contents: write`, `id-token: write`, `attestations: write`, -and `artifact-metadata: write`. Every third-party or GitHub action is pinned to -a complete commit SHA. - -## First crates.io publication - -crates.io trusted publishing cannot be configured before the package exists. -For the first publication only: +Create a GitHub environment named `release` and apply the repository's release +branch restrictions. It holds the `RELEASE_PLZ_APP_CLIENT_ID` variable and the +`RELEASE_PLZ_APP_PRIVATE_KEY` secret for the installed `p4suta-release-plz` +App, which needs repository **Contents** and **Pull requests** read/write. + +The App token is there because the default `GITHUB_TOKEN` cannot trigger other +workflows, so CI would never run on a release pull request; release-plz +documents this and uses an App itself. Every third-party or GitHub action is +pinned to a complete 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 an unrelated merge cannot +release: **merging a reviewed release pull request is what authorises a +publish.** Let release-plz own the version bump; editing `version` by hand +still reaches the registry but skips the review the release pull request +exists to provide. + +release-plz publishes the crate and creates `vX.Y.Z`. `git_release_enable = +false` leaves the GitHub release to `release-finalize.yml`, which the same run +calls: draft releases fire no release event, so there is nothing to hook +instead, and the crate, SBOMs, checksums, and attestations have to be attached +before the release is published. + +The finalizer verifies the tag, rebuilds the release candidate, and requires +the rebuilt archive's SHA-256 to equal the crate crates.io actually serves +before attesting anything. It then creates SLSA v1 build provenance and +CycloneDX SBOM attestations, uploads the crate, both SBOMs, and checksums to a +draft GitHub Release, and only then makes it public. A failure can leave a +draft release; inspect and remove that draft before re-running. + +## crates.io credentials + +A trusted publisher can only be registered against a crate that already exists, +so the first publication of a crate needs a token: 1. Create a short-lived crates.io API token and store it as the - `CRATES_IO_BOOTSTRAP_TOKEN` secret on the protected `release` environment. -2. Manually run the **Release** workflow for the existing tag with - `publish_crates_io` enabled, then approve the environment deployment. -3. Immediately remove the environment secret and revoke the crates.io token. -4. Configure the crate's crates.io trusted publisher for this repository, - `.github/workflows/release.yml`, and the `release` environment. - -For later publications, leave `CRATES_IO_BOOTSTRAP_TOKEN` absent. The workflow -uses the crates.io authentication action to obtain and revoke a short-lived -OIDC token. - -## Publishing and verification - -After approval, the workflow regenerates the candidate, creates SLSA v1 build -provenance and CycloneDX SBOM attestations, uploads the crate, both SBOMs, and -checksums to a draft GitHub Release, optionally publishes to crates.io, and -only then makes the GitHub Release public. A failure can leave a draft release; -inspect and remove that draft before retrying the same tag. + `CRATES_IO_BOOTSTRAP_TOKEN` secret on the `release` environment. +2. Release normally. `cargo xtask crates-io-auth-mode` reports which credential + the run selected. +3. Configure the crate's crates.io trusted publisher for this repository, + `.github/workflows/release-plz.yml`, and the `release` environment. +4. Remove the environment secret and revoke the crates.io token. Later releases + then use only the short-lived OpenID Connect exchange. + +## Verification Consumers can download the release assets, verify `SHA256SUMS`, and verify provenance with: diff --git a/release-plz.toml b/release-plz.toml new file mode 100644 index 0000000..db95f82 --- /dev/null +++ b/release-plz.toml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: 2026 Yasunobu Sakashita +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +[workspace] +allow_dirty = false +changelog_update = true +dependencies_update = false +# `release_always` is left at its default of true. Setting it to false makes +# release-plz publish only from a commit it authored through its own release +# pull request, so a version bumped by hand leaves the crate unreleasable: no +# release pull request is proposed because the version is already ahead of the +# registry, and no release happens because there was no release pull request. +git_tag_enable = true +git_tag_name = "v{{ version }}" +# release-plz publishes the crate and creates the tag. The GitHub release is +# left to release-finalize.yml, which needs to attach the crate, SBOM, and +# attestations before anything is published. +git_release_enable = false +publish = true +semver_check = true + +[[package]] +name = "xtask" +release = false + +[changelog] +protect_breaking_commits = true +tag_pattern = "^v[0-9]+\\.[0-9]+\\.[0-9]+$"