From b2402962a056d26660927b87a03df4c12df12ed6 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 29 Sep 2026 01:49:57 -0700 Subject: [PATCH] A tag's run refuses a commit whose CI did not pass and sources whose signatures or provenance do not verify, the gates CI runs on every push, before it builds a release --- .github/workflows/distro.yml | 35 +++++++++++++++++++++++++++++++++++ docs/releases.md | 7 +++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.github/workflows/distro.yml b/.github/workflows/distro.yml index 6db2e27c..17133c35 100644 --- a/.github/workflows/distro.yml +++ b/.github/workflows/distro.yml @@ -58,6 +58,10 @@ env: jobs: toolchain: name: Stages 01-02 (cross toolchain, temporary tools) + # actions: read, for the tag preflight to read CI's verdict on the commit. + permissions: + contents: read + actions: read runs-on: ubuntu-24.04 timeout-minutes: 360 outputs: @@ -89,6 +93,37 @@ jobs: with: sources: fetch + # A release takes only sources whose signatures verify against keys a + # publisher states, whose provenance holds, and a commit whose CI + # passed: the gates CI runs on every push, run again here so a tag + # never outruns them. + - name: The release's sources are signed, and their provenance holds + if: github.ref_type == 'tag' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ./tools/verify-signatures.sh --strict + ./tools/verify-provenance.sh --strict + - name: CI passed on the tagged commit + if: github.ref_type == 'tag' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + sha="$(git rev-parse 'HEAD^{commit}')" + status=none; conclusion=- + for try in $(seq 1 40); do + read -r status conclusion < <(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${sha}&per_page=1" \ + --jq '.workflow_runs[0] | "\(.status // "none") \(.conclusion // "-")"') + case "$status" in + completed) break ;; + none) echo "no CI run for ${sha}: a release is cut from a commit CI has tested"; exit 1 ;; + *) echo "CI is ${status} on ${sha}; waiting (${try}/40)"; sleep 60 ;; + esac + done + [ "$status" = completed ] || { echo "CI did not finish on ${sha} in time"; exit 1; } + [ "$conclusion" = success ] || { echo "CI concluded ${conclusion} on ${sha}: a release is cut only from a commit CI passed"; exit 1; } + echo "CI passed on ${sha}" + # The newest tree, whatever built it: its stamps decide what rebuilds, # and 01-toolchain.sh clears one that another toolchain built. A tag # restores nothing: a release holds only what a build from nothing diff --git a/docs/releases.md b/docs/releases.md index 3b2e55d0..8c9f57ec 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -36,8 +36,11 @@ the release key made offline. The dated builds CI makes of every push to main ``` 3. The Distro workflow refuses a held pin (`check-pin-reviews.sh - --no-held`), then builds that version from nothing, with no cached tree, - so the release holds only what a clean build makes: about three hours. It + --no-held`), a source whose signature or provenance does not verify + (`verify-signatures.sh --strict`, `verify-provenance.sh --strict`) and a + commit whose CI did not pass, then builds that version from nothing, with + no cached tree, so the release holds only what a clean build makes: about + three hours. It builds it as the release under test, over a `0.0.0` build the update suite updates from, and runs every suite on the images. For a development release (`v0.x`) its last job drafts the release: the export, the