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
60 changes: 60 additions & 0 deletions .github/actions/install-release-toolchain/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Install release toolchain
description: >-
Fetch, constrain, and hash-verify the publication-side Python toolchain
without checking out or executing any project code.

inputs:
lockfile-url:
description: >-
Raw URL of the hash-locked requirements file, pinned to an immutable
commit SHA. Fetched rather than read from a checkout so the jobs that
hold write or OIDC authority need no repository checkout at all.
required: true

runs:
using: composite
steps:
- name: Fetch and constrain the locked toolchain
shell: bash
env:
LOCKFILE_URL: ${{ inputs.lockfile-url }}
run: |
set -euo pipefail
curl --fail --location --proto '=https' --proto-redir '=https' \
--output release-toolchain.txt "${LOCKFILE_URL}"

# `--require-hashes` proves the downloaded bytes match the digests in
# the lockfile — but the lockfile itself comes from the candidate
# commit, so it constrains *integrity*, not *choice*. Nothing in it
# stops a candidate adding a package, and its build hooks, to a job
# that can mint a PyPI token or rewrite a public release.
#
# The allowlist below is the constraint on choice. It lives here, in
# `.github/**`, so widening the set is a small reviewed diff rather
# than one more line in a 400-line generated file where it would pass
# unnoticed. That raises the review floor; it is not an independent
# trust root, because this action is itself part of the tagged
# candidate. See the Deployment prerequisites in
# docs/release-runbook.md.
allowed="annotated-types certifi cffi charset-normalizer cryptography"
allowed="${allowed} dnspython email-validator id idna markdown-it-py mdurl"
allowed="${allowed} platformdirs pyasn1 pycparser pydantic pydantic-core"
allowed="${allowed} pygments pyjwt pyopenssl requests rfc3161-client rfc8785"
allowed="${allowed} rich securesystemslib sigstore sigstore-models"
allowed="${allowed} sigstore-rekor-types tuf typing-extensions"
allowed="${allowed} typing-inspection urllib3 uv"

grep -oE '^[A-Za-z0-9][A-Za-z0-9._-]*==' release-toolchain.txt \
| sed 's/==$//' | tr 'A-Z_.' 'a-z--' | sort -u > requested-toolchain.txt
while read -r name; do
case " ${allowed} " in
*" ${name} "*) ;;
*)
echo "::error::Release toolchain lockfile requests ${name}, which is not on the allowlist in .github/actions/install-release-toolchain/action.yml."
exit 1
;;
esac
done < requested-toolchain.txt
echo "OK: every locked distribution is on the allowlist."

python -m pip install --require-hashes --requirement release-toolchain.txt
26 changes: 26 additions & 0 deletions .github/release-trust-roots.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"$comment": [
"Trust roots for release safety qualification. Reviewed code, not mutable configuration.",
"",
"These two values are the allowlist that authenticates the signed qualification",
"artifact. They must not live in repository or environment variables: an actor able",
"to set variables could otherwise substitute fabricated qualification evidence AND",
"replace the identity that authenticates it, in one step, with no diff to review.",
"Source-to-wheel binding does not compensate, because that attack reuses the",
"legitimate wheel and forges only the safety claims about it.",
"",
"Only content-addressed *locations* stay mutable (the SAFETY_QUALIFICATION_*_URL",
"variables and the wheel filename): pointing them somewhere else fails either the",
"signature check against this file or the source-to-wheel provenance gate.",
"",
"signer_identity is the exact Sigstore certificate identity of the qualification",
"promotion job, for example the workflow ref that signs safety-qualification.json.",
"Both values are CHANGE_ME until the promotion flow exists; the release refuses to",
"run while either is unset rather than defaulting to something permissive.",
"",
"Changing either value below is a trust-root change and must be reviewed as one.",
"See docs/release-runbook.md."
],
"signer_identity": "CHANGE_ME",
"oidc_issuer": "https://token.actions.githubusercontent.com"
}
54 changes: 54 additions & 0 deletions .github/workflows/release-rehearsal.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Release Rehearsal

# A release candidate used to be first-run at the same moment publication
# became possible: the only way to exercise release.yml was to push a `v*` tag,
# which is also the only thing that can publish. Steps added after v0.15.0 had
# therefore never executed when they were relied upon to gate a release.
#
# This workflow runs the identical verification path — same build, same
# qualification validation, same tests, same audit, same SBOM, same
# content-addressed handoff — by calling the same reusable workflow release.yml
# calls. What it does not contain is a publication job.
#
# Three independent things prevent this from publishing, so a misconfigured
# step cannot become a release:
#
# 1. There is no publication job in this file to instantiate.
# 2. `permissions: contents: read` at workflow level. A job here cannot be
# granted write, so `gh release create` and tag creation fail.
# 3. No `id-token: write` anywhere, so PyPI Trusted Publishing cannot mint a
# token. There is no `environment: pypi` either.
#
# See docs/release-runbook.md: a rehearsal on the candidate commit is a
# prerequisite for pushing a release tag.

on:
workflow_dispatch:
inputs:
release_tag:
description: >-
Tag to rehearse against. Leave empty to derive v<pyproject version>,
which is what the real tag will be.
required: false
type: string
default: ""

permissions:
contents: read

jobs:
rehearse:
name: Rehearse candidate
permissions:
contents: read
uses: ./.github/workflows/release-verify.yml
with:
# `github.sha` — the immutable commit the dispatch resolved to — never a
# branch name or a free-form ref input. The verification workflow checks
# out independently in its `tests` and `artifact` jobs, so a mutable ref
# could otherwise be tested as commit A and sealed as commit B, while
# `stage` later located the run by its original `head_sha`. Choose which
# commit to rehearse with the workflow_dispatch ref selector.
ref: ${{ github.sha }}
release_tag: ${{ inputs.release_tag }}
mode: rehearsal
Loading