From 16ee5f474f5eb7bf615222f885044e3226369428 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 28 Sep 2026 13:20:30 -0400 Subject: [PATCH 1/3] Attest a published release that missed its attestation v1.7.0 published its assets but the release run stopped at the size report before "Attest build provenance", so it shipped without GitHub attestations. mise requires them once an earlier version had them, and refuses the upgrade (#499), which also breaks hey upgrade on installs managed by mise. Re-running the release job would rebuild and re-sign, producing digests that are not what was published. The new "Attest a published release" workflow attests the release as it stands instead: it verifies checksums.txt against the release run's own cosign bundle (release.yml at the tag, the identity the installers pin), checks every checksum against the published asset's digest, and then attests checksums.txt the way release.yml does. mise does not pin the signer workflow, so an attestation signed by this workflow satisfies it. --- .github/workflows/attest-release.yml | 107 ++++++++++++++++++++ .github/workflows/sensitive-change-gate.yml | 1 + RELEASING.md | 20 ++++ tests/e2e/attest_release_workflow.bats | 40 ++++++++ 4 files changed, 168 insertions(+) create mode 100644 .github/workflows/attest-release.yml create mode 100644 tests/e2e/attest_release_workflow.bats diff --git a/.github/workflows/attest-release.yml b/.github/workflows/attest-release.yml new file mode 100644 index 00000000..d621b8ac --- /dev/null +++ b/.github/workflows/attest-release.yml @@ -0,0 +1,107 @@ +# Manual build-provenance attestation, for a release whose run published the +# assets but never reached release.yml's "Attest build provenance" step. +# +# v1.7.0 is the case this exists for: the size report after goreleaser failed, +# the steps behind it were skipped, and the release shipped without +# attestations. mise requires them once an earlier version of a tool had them, +# so `mise upgrade` and `hey upgrade` via mise refused the release (#499). +# Re-running the release job is no answer: goreleaser would either refuse the +# existing release or rebuild and re-sign, and the new binaries would not be +# the bytes users download. +# +# This workflow attests what is already published and nothing else. It trusts +# checksums.txt only after the release run's own cosign bundle verifies against +# release.yml at the tag, and every checksum only if the published asset of +# that name carries the same digest. The attestation's signer is this workflow +# rather than release.yml; mise does not pin the signer workflow, and neither +# the installers nor `hey upgrade` read attestations at all. +name: Attest a published release + +on: + workflow_dispatch: + inputs: + tag: + description: 'Published release tag to attest (e.g. v1.7.0)' + required: true + type: string + +permissions: {} + +concurrency: + group: attest-release-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + attest: + name: Attest build provenance + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: release + permissions: + contents: read + id-token: write + attestations: write + steps: + - name: Validate tag input + env: + TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ github.token }} + run: | + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid tag '${TAG}' — expected a release tag such as v1.7.0" + exit 1 + fi + # A draft's assets are not what anyone downloads, so there is + # nothing there to attest yet. + draft=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" --jq '.draft' 2>/dev/null) || { + echo "::error::No published release found for ${TAG}" + exit 1 + } + if [ "$draft" != "false" ]; then + echo "::error::${TAG} is a draft release" + exit 1 + fi + + - name: Download the release's checksums and their signature + env: + TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ github.token }} + run: | + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ + --pattern checksums.txt --pattern checksums.txt.bundle + + - name: Install Cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + # The same identity the installers and `hey upgrade` pin: only a + # checksums.txt signed by the release run for this tag is attested. + - name: Verify checksums.txt was signed by the release run + env: + TAG: ${{ inputs.tag }} + run: | + cosign verify-blob checksums.txt \ + --bundle checksums.txt.bundle \ + --certificate-identity "https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@refs/tags/${TAG}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com + + - name: Verify every checksum matches the published asset + env: + TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ github.token }} + run: | + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \ + --jq '.assets[] | select(.digest != null) | "\(.digest | ltrimstr("sha256:")) \(.name)"' \ + | sort > published.txt + sort checksums.txt > signed.txt + missing=$(comm -23 signed.txt published.txt) + if [ -n "$missing" ]; then + echo "::error::checksums.txt lists entries no published asset matches:" + echo "$missing" + exit 1 + fi + echo "All $(wc -l < signed.txt) signed checksums match published assets" + + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-checksums: ./checksums.txt diff --git a/.github/workflows/sensitive-change-gate.yml b/.github/workflows/sensitive-change-gate.yml index 9a05d15c..0568095c 100644 --- a/.github/workflows/sensitive-change-gate.yml +++ b/.github/workflows/sensitive-change-gate.yml @@ -13,6 +13,7 @@ jobs: extra-patterns: | .goreleaser.yaml .github/workflows/release.yml + .github/workflows/attest-release.yml scripts/release.sh scripts/stamp-nix-version.sh scripts/sign-windows.sh diff --git a/RELEASING.md b/RELEASING.md index 72fc67e3..75f7a7a2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -260,6 +260,26 @@ it is idempotent and refuses downgrades. One-time setup: `ssh-keygen -t ed25519 -f aur_key`, add the public key to the AUR account, store the private key as `AUR_KEY`. +## Missing attestation + +mise refuses a version without GitHub build provenance once an earlier version +of the tool had it, so a release whose run published but stopped before +`Attest build provenance` cannot be installed or upgraded through mise (v1.7.0, +#499). Do not re-run the release job: goreleaser would rebuild and re-sign, and +the new digests are not what was published. Dispatch the `Attest a published +release` workflow with the tag instead: + +```bash +gh workflow run attest-release.yml -f tag=v1.7.0 +gh attestation verify hey_1.7.0_linux_amd64.tar.gz --repo basecamp/hey-cli +``` + +It attests the published `checksums.txt` only after the release run's +`checksums.txt.bundle` verifies against `release.yml` at that tag and every +checksum matches the digest of the published asset of that name. The +attestation's signer is `attest-release.yml` rather than `release.yml`; mise +does not check the signer workflow. + ## Skills sync Stable releases mirror `skills/` into [basecamp/skills](https://github.com/basecamp/skills), diff --git a/tests/e2e/attest_release_workflow.bats b/tests/e2e/attest_release_workflow.bats new file mode 100644 index 00000000..4ca62ee5 --- /dev/null +++ b/tests/e2e/attest_release_workflow.bats @@ -0,0 +1,40 @@ +#!/usr/bin/env bats +# attest_release_workflow.bats - static contracts for the manual attestation +# workflow. actionlint validates syntax; these assertions validate intent. + +setup() { + REPO_ROOT="$(cd "${BATS_TEST_DIRNAME}/../.." && pwd)" + WORKFLOW="$REPO_ROOT/.github/workflows/attest-release.yml" +} + +step_order() { + grep -n -- "- name: $1" "$WORKFLOW" | head -1 | cut -d: -f1 +} + +@test "checksums are trusted only when the release run for the tag signed them" { + run cat "$WORKFLOW" + [[ "$output" == *'.github/workflows/release.yml@refs/tags/${TAG}'* ]] + [[ "$output" == *"--certificate-oidc-issuer https://token.actions.githubusercontent.com"* ]] +} + +@test "attestation comes after both verifications" { + signed=$(step_order "Verify checksums.txt was signed by the release run") + published=$(step_order "Verify every checksum matches the published asset") + attest=$(step_order "Attest build provenance") + [ -n "$signed" ] && [ -n "$published" ] && [ -n "$attest" ] + [ "$signed" -lt "$attest" ] + [ "$published" -lt "$attest" ] +} + +@test "it attests the release's own checksums, as release.yml does" { + run grep -c "subject-checksums: ./checksums.txt" "$WORKFLOW" + [ "$output" = "1" ] + expected=$(grep -o "actions/attest-build-provenance@[0-9a-f]*" "$REPO_ROOT/.github/workflows/release.yml") + actual=$(grep -o "actions/attest-build-provenance@[0-9a-f]*" "$WORKFLOW") + [ "$expected" = "$actual" ] +} + +@test "it cannot write to the repository" { + run grep -E "contents: write|actions: write" "$WORKFLOW" + [ "$status" -ne 0 ] +} From 409dc55124a8c6e3259815af665d3895c3b5a9b0 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 28 Sep 2026 13:24:41 -0400 Subject: [PATCH 2/3] Pin the attestation workflow's read access in its test Rejecting write permissions alone let contents: read be deleted, which would leave the release download and the digest check unable to read the release. --- tests/e2e/attest_release_workflow.bats | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/e2e/attest_release_workflow.bats b/tests/e2e/attest_release_workflow.bats index 4ca62ee5..612b93e4 100644 --- a/tests/e2e/attest_release_workflow.bats +++ b/tests/e2e/attest_release_workflow.bats @@ -34,7 +34,9 @@ step_order() { [ "$expected" = "$actual" ] } -@test "it cannot write to the repository" { +@test "it can read the release but cannot write to the repository" { run grep -E "contents: write|actions: write" "$WORKFLOW" [ "$status" -ne 0 ] + run grep -c "^ contents: read$" "$WORKFLOW" + [ "$output" = "1" ] } From 2aaeb474d09b31d2f9ed70d2a77404482e2d33db Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 28 Sep 2026 13:24:41 -0400 Subject: [PATCH 3/3] Name the release assets as what the recovery workflow attests subject-checksums treats checksums.txt as an index of subject names and digests, so the attestations are for the assets it lists, not for the file. That is why the verification example names an archive. --- .github/workflows/attest-release.yml | 18 ++++++++++-------- RELEASING.md | 10 ++++++---- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/workflows/attest-release.yml b/.github/workflows/attest-release.yml index d621b8ac..b4dd75c4 100644 --- a/.github/workflows/attest-release.yml +++ b/.github/workflows/attest-release.yml @@ -9,12 +9,13 @@ # existing release or rebuild and re-sign, and the new binaries would not be # the bytes users download. # -# This workflow attests what is already published and nothing else. It trusts -# checksums.txt only after the release run's own cosign bundle verifies against -# release.yml at the tag, and every checksum only if the published asset of -# that name carries the same digest. The attestation's signer is this workflow -# rather than release.yml; mise does not pin the signer workflow, and neither -# the installers nor `hey upgrade` read attestations at all. +# This workflow attests the published assets and nothing else. checksums.txt is +# the list of subjects handed to attest-build-provenance, not a subject itself. +# It is trusted only after the release run's own cosign bundle verifies against +# release.yml at the tag, and each listed digest only if the published asset of +# that name carries it. The attestation's signer is this workflow rather than +# release.yml; mise does not pin the signer workflow, and the installers and +# `hey upgrade`'s own verification do not read attestations at all. name: Attest a published release on: @@ -73,8 +74,9 @@ jobs: - name: Install Cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - # The same identity the installers and `hey upgrade` pin: only a - # checksums.txt signed by the release run for this tag is attested. + # The same identity the installers and `hey upgrade` pin: only the + # assets listed in a checksums.txt the release run for this tag signed + # are attested. - name: Verify checksums.txt was signed by the release run env: TAG: ${{ inputs.tag }} diff --git a/RELEASING.md b/RELEASING.md index 75f7a7a2..7e8347d2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -274,11 +274,13 @@ gh workflow run attest-release.yml -f tag=v1.7.0 gh attestation verify hey_1.7.0_linux_amd64.tar.gz --repo basecamp/hey-cli ``` -It attests the published `checksums.txt` only after the release run's +`checksums.txt` is the index of subjects, not the subject: the workflow +attests each release asset it lists, by name and digest, which is why the check +above names an archive. It does so only after the release run's `checksums.txt.bundle` verifies against `release.yml` at that tag and every -checksum matches the digest of the published asset of that name. The -attestation's signer is `attest-release.yml` rather than `release.yml`; mise -does not check the signer workflow. +listed digest matches the published asset of that name. The attestation's +signer is `attest-release.yml` rather than `release.yml`; mise does not check +the signer workflow. ## Skills sync