From a07b73f020187f8825933265cee67d4d9fc63405 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sat, 26 Sep 2026 15:32:18 -0400 Subject: [PATCH 1/2] Provenance: each release's tarball attested (SLSA) and held to the bytes hex.pm serves .github/workflows/provenance.yml, after beam_mcp's: on a v* tag CI builds the tarball with tools/release_tarball.sh, attests its digest with actions/attest-build-provenance (pinned by commit), verifies it with --signer-workflow, and holds it to the bytes hex.pm serves (a fetch or a mismatch fails the tag's run). A manual run takes `ref`, a published tag, and is held to hex.pm the same way, so 0.2.1, released before this workflow, can be attested now. docs/verifying-releases.md gives the verify command; CHANGELOG entry under Unreleased. Signed-off-by: Ayla Croft --- .github/workflows/provenance.yml | 116 +++++++++++++++++++++++++++++++ CHANGELOG.md | 7 ++ docs/verifying-releases.md | 17 +++++ 3 files changed, 140 insertions(+) create mode 100644 .github/workflows/provenance.yml diff --git a/.github/workflows/provenance.yml b/.github/workflows/provenance.yml new file mode 100644 index 0000000..e1582f6 --- /dev/null +++ b/.github/workflows/provenance.yml @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Build provenance for the Hex tarball, as beam_mcp's provenance workflow does it: CI builds +# the tarball the canonical way (tools/release_tarball.sh: `git archive` at the ref, every +# file 644), attests its digest (SLSA build provenance, Sigstore-signed, GitHub's attestation +# store), and verifies the attestation against the tarball it built and against the bytes +# hex.pm serves for that version. The tarball's sha256 is hex.pm's package checksum, so a +# match binds the attestation to the published package. +# +# THE OWNER TAGS AND PUBLISHES; THIS WORKFLOW ATTESTS. It never runs `mix hex.publish`. Order: +# publish, then push the tag; on the tag's run a version hex.pm does not serve, or serves as +# other bytes, is a failure. +# +# A manual run takes `ref`, a tag already published (for example v0.2.1, released before this +# workflow existed): it builds that tag's tree with this workflow and holds the result to +# hex.pm's bytes the same way, so a failure to fetch or match fails the run. With no `ref`, +# a manual run attests the branch head, which is nobody's release, and its hex.pm half reads +# NOT MEASURED. + +name: provenance + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + ref: + description: "A published release tag to attest (e.g. v0.2.1); empty attests the branch head" + required: false + default: "" + +permissions: + contents: read + +jobs: + attest: + name: Provenance - build, attest, verify + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + # The pinned pair, a copy of .tool-versions' line. + - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 + with: + otp-version: "28" + elixir-version: "1.18" + - name: the tarball, built the canonical way, its version and its sha256 (hex.pm's package checksum) + id: build + env: + INPUT_REF: ${{ inputs.ref }} + run: | + set -euo pipefail + ref="${INPUT_REF:-${GITHUB_SHA}}" + case "$ref" in + "${GITHUB_SHA}") claimed="${{ github.ref_type == 'tag' }}" ;; + v*) git rev-parse --verify --quiet "refs/tags/${ref}" >/dev/null || { echo "no tag ${ref}"; exit 1; }; claimed=true ;; + *) echo "ref must be a release tag (v...) or empty"; exit 1 ;; + esac + version=$(git show "${ref}:mix.exs" | sed -nE 's/^ @version "([^"]+)"$/\1/p' | head -1) + [ -n "$version" ] || { echo "no @version in ${ref}'s mix.exs"; exit 1; } + tools/release_tarball.sh "${ref}" "beam_mcp_signer-${version}.tar" | tee build.out + sha=$(sha256sum "beam_mcp_signer-${version}.tar" | cut -d' ' -f1) + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "sha256=${sha}" >> "$GITHUB_OUTPUT" + echo "claimed=${claimed}" >> "$GITHUB_OUTPUT" + echo "tarball beam_mcp_signer-${version}.tar from ${ref}: sha256 ${sha}" + # Pinned by commit (v4.2.2), as in beam_mcp. + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: beam_mcp_signer-${{ steps.build.outputs.version }}.tar + - name: verify the attestation against the tarball this run built + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh attestation verify "beam_mcp_signer-${{ steps.build.outputs.version }}.tar" --repo "${GITHUB_REPOSITORY}" \ + --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/provenance.yml" + - name: verify the attestation against the bytes hex.pm serves for this version + env: + GH_TOKEN: ${{ github.token }} + CLAIMED: ${{ steps.build.outputs.claimed }} + run: | + set -uo pipefail + v="${{ steps.build.outputs.version }}" + code=$(curl -sSL -o "published-${v}.tar" -w '%{http_code}' "https://repo.hex.pm/tarballs/beam_mcp_signer-${v}.tar") || code="000" + if [ "${code}" != "200" ]; then + if [ "${CLAIMED}" = "true" ]; then + echo "FAIL: hex.pm answered ${code} for beam_mcp_signer ${v} -- publish first, then push the tag (404: not published; anything else: the fetch failed)" + exit 1 + fi + echo "NOT MEASURED: hex.pm answered ${code} for beam_mcp_signer ${v}; a run on a branch head attests nobody's release" + exit 0 + fi + pub=$(sha256sum "published-${v}.tar" | cut -d' ' -f1) + echo "published sha256 ${pub}; built ${{ steps.build.outputs.sha256 }}" + if [ "${pub}" != "${{ steps.build.outputs.sha256 }}" ]; then + if [ "${CLAIMED}" = "true" ]; then + echo "FAIL: the published tarball is not the bytes this tag builds canonically; it was not published with tools/release_tarball.sh" + exit 1 + fi + echo "NOT MEASURED: hex.pm serves ${v} but not this commit's bytes; a run on a branch head attests nobody's release" + exit 0 + fi + gh attestation verify "published-${v}.tar" --repo "${GITHUB_REPOSITORY}" \ + --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/provenance.yml" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: tarball-${{ steps.build.outputs.version }} + path: beam_mcp_signer-${{ steps.build.outputs.version }}.tar + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index 89885f5..f3c8505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ SPDX-License-Identifier: Apache-2.0 ## [Unreleased] +### Added: build provenance for each release + +- `.github/workflows/provenance.yml`: on a release tag, CI builds the tarball with + `tools/release_tarball.sh`, attests its digest (SLSA build provenance), and verifies the + attestation against what hex.pm serves. A manual run takes a published tag, so `0.2.1` is + attested after its release. `docs/verifying-releases.md` gives the command. + ## [0.2.1] - 2026-09-26 A security fix: two more ways the private key could reach an exception report are closed. diff --git a/docs/verifying-releases.md b/docs/verifying-releases.md index 07c57a0..fb333e5 100644 --- a/docs/verifying-releases.md +++ b/docs/verifying-releases.md @@ -39,3 +39,20 @@ The script builds from `git archive` of the tag (tracked files only, every file two machines produce the same bytes. A release published with the script (`--publish`) has the checksum hex.pm shows; `0.1.0` and `0.1.1` were built from a working tree before the script existed, and are verified by their signed tags only. + +## Build provenance + +From `0.2.1` each release's tarball carries a SLSA build-provenance attestation, made by +`.github/workflows/provenance.yml`: CI builds the tarball from the tag with the same script, +attests its digest (Sigstore-signed, in GitHub's attestation store), and checks that the +bytes hex.pm serves are the bytes it attested. `0.2.1` was attested after its release, by a +manual run of the workflow on its tag; later releases are attested by the tag's own run. + +```sh +v=0.2.1 +curl -fsSLO "https://repo.hex.pm/tarballs/beam_mcp_signer-${v}.tar" +gh attestation verify "beam_mcp_signer-${v}.tar" --repo ScriptKittyOS/beam_mcp_signer \ + --signer-workflow ScriptKittyOS/beam_mcp_signer/.github/workflows/provenance.yml +``` + +`gh attestation` needs GitHub CLI 2.49 or newer. From 8879a1d7f69b5f18b113fbde818a6b0f8b2eb0e3 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sat, 26 Sep 2026 15:32:55 -0400 Subject: [PATCH 2/2] GOVERNANCE: main is protected by a ruleset, as beam_mcp's is Signed-off-by: Ayla Croft --- CHANGELOG.md | 5 +++++ GOVERNANCE.md | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3c8505..5a0a465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ SPDX-License-Identifier: Apache-2.0 attestation against what hex.pm serves. A manual run takes a published tag, so `0.2.1` is attested after its release. `docs/verifying-releases.md` gives the command. +### Changed: `main` is protected by a ruleset (no code change) + +- `GOVERNANCE.md`: how a change lands is now enforced on `main` (pull requests only, rebase + merges, linear history, the five checks required, no bypass), where it had been practice. + ## [0.2.1] - 2026-09-26 A security fix: two more ways the private key could reach an exception report are closed. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 7a91837..5612e7c 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -27,8 +27,13 @@ date, so the bus factor is two, and his reviews are the public record of it. ## How a change lands 1. A branch and a pull request; nothing is pushed to `main` directly. -2. CI on three OTP/Elixir pairs (the floor, the pinned line, the newest) and the DCO check. +2. CI on three OTP/Elixir pairs (the floor, the pinned line, the newest), the REUSE lint and + the DCO check. 3. The maintainer's review, then a rebase merge. + +From 2026-09-26 a repository ruleset on `main` holds steps 1 to 3 rather than leaving them to +habit, as `beam_mcp`'s does: no deletion or force-push, changes by pull request only, a rebase +merge and linear history, and the five checks above required and up to date, with no bypass. 4. A release is a signed tag and a hex.pm publish by the maintainer, recorded in the CHANGELOG; `docs/verifying-releases.md` says how to check one.