diff --git a/.github/workflows/attest-release.yml b/.github/workflows/attest-release.yml new file mode 100644 index 00000000..b4dd75c4 --- /dev/null +++ b/.github/workflows/attest-release.yml @@ -0,0 +1,109 @@ +# 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 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: + 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 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 }} + 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..7e8347d2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -260,6 +260,28 @@ 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 +``` + +`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 +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 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..612b93e4 --- /dev/null +++ b/tests/e2e/attest_release_workflow.bats @@ -0,0 +1,42 @@ +#!/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 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" ] +}