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]+$"