Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions .github/workflows/attest-release.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .github/workflows/sensitive-change-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
42 changes: 42 additions & 0 deletions tests/e2e/attest_release_workflow.bats
Original file line number Diff line number Diff line change
@@ -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" ]
}
Loading