From 60ac7e91abd58a5a41958cf5db868a0feedb3569 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Sun, 9 Aug 2026 14:06:35 -0700 Subject: [PATCH 1/8] fix(release): bind the published wheel to the tagged source and make publication recoverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tag workflow established three bindings — tag to pyproject version, qualification payload to wheel bytes, and tag to the wheel's own METADATA version — but nothing tied the shipped bytes back to any source tree. It tested the checkout and published a wheel downloaded from a repository variable, so any wheel declaring Name: agents-shipgate and the right Version satisfied every check. It also ran verification and immutable publication in one job, generated an SBOM describing the CI machine, ran the suite serially including timing-sensitive perf tests, and could only be exercised by pushing a tag — the one action that also publishes. - #342 Rebuild from the tagged checkout and require byte equality with the qualified wheel before publication. Pin the build backend so byte equality is achievable at all: wheels record `Generator: hatchling `. Container-metadata-only differences still fail; the weaker unpacked-content bar sits behind an explicit flag so it cannot be taken silently. - #343 Split read-only verification from publication with a content-addressed handoff whose digest travels via job outputs. Draft the GitHub Release before the PyPI upload, finalize after asset validation, serialize concurrency across the project, and make the upload idempotence-aware so a re-run completes an interrupted transaction but never republishes divergent bytes. - #344 Match CI's -n auto parallelism, exclude perf-marked latency budgets, keep the adapter static-only lint explicit, and size the timeout from a measurement. - #355 Add a workflow_dispatch rehearsal that runs the identical verification path and is structurally incapable of publishing. - #356 Generate the SBOM from an isolated runtime-only install of the wheel, bind it to the wheel digest, and normalize away the file:// build path that leaked runner layout and broke determinism. #341 is deliberately not decided here: the pre-1.0 evidence bar is a human product/security decision. docs/release-evidence-policy-decision.md records both routes, the invariants that stay non-negotiable either way, and what a rehearsal must prove; the enforced policy is unchanged. Refs #342, #343, #344, #355, #356, #341 Co-Authored-By: Claude Opus 5 --- .github/workflows/release-rehearsal.yml | 53 ++ .github/workflows/release-verify.yml | 360 ++++++++++++++ .github/workflows/release.yml | 267 ++++++----- CHANGELOG.md | 69 +++ constraints/release-build.txt | 21 + docs/INDEX.md | 2 + docs/distribution.md | 44 +- docs/release-evidence-policy-decision.md | 118 +++++ docs/release-runbook.md | 187 ++++++++ scripts/release_publication.py | 265 ++++++++++ scripts/release_sbom.py | 266 ++++++++++ scripts/verify_wheel_provenance.py | 227 +++++++++ tests/test_action_metadata.py | 20 +- tests/test_release_pipeline.py | 534 +++++++++++++++++++++ tests/test_safety_qualification_release.py | 72 ++- 15 files changed, 2334 insertions(+), 171 deletions(-) create mode 100644 .github/workflows/release-rehearsal.yml create mode 100644 .github/workflows/release-verify.yml create mode 100644 constraints/release-build.txt create mode 100644 docs/release-evidence-policy-decision.md create mode 100644 docs/release-runbook.md create mode 100644 scripts/release_publication.py create mode 100644 scripts/release_sbom.py create mode 100644 scripts/verify_wheel_provenance.py create mode 100644 tests/test_release_pipeline.py diff --git a/.github/workflows/release-rehearsal.yml b/.github/workflows/release-rehearsal.yml new file mode 100644 index 00000000..f224683f --- /dev/null +++ b/.github/workflows/release-rehearsal.yml @@ -0,0 +1,53 @@ +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: + ref: + description: Branch, tag or SHA to rehearse. Defaults to the dispatch ref. + required: false + type: string + default: "" + release_tag: + description: >- + Tag to rehearse against. Leave empty to derive v, + 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: + ref: ${{ inputs.ref || github.ref }} + release_tag: ${{ inputs.release_tag }} + mode: rehearsal diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml new file mode 100644 index 00000000..0be2de02 --- /dev/null +++ b/.github/workflows/release-verify.yml @@ -0,0 +1,360 @@ +name: Release Verification + +# Read-only verification of a release candidate. Called by release.yml (before +# publication) and by release-rehearsal.yml (instead of publication), so the +# rehearsal exercises the identical build, qualification, test, audit, SBOM and +# handoff path rather than an approximation of it. +# +# This workflow never publishes. It holds `contents: read` and no `id-token`, +# which caps what any caller can grant it: a reusable workflow cannot be +# handed more permission than it declares. That is what makes the rehearsal +# structurally incapable of publishing rather than merely configured not to. + +on: + workflow_call: + inputs: + ref: + description: Git ref to verify. A tag for a release, any ref for a rehearsal. + required: true + type: string + release_tag: + description: >- + Tag the candidate claims. Empty means "derive v", + which is what a rehearsal on a branch checks against. + required: false + type: string + default: "" + mode: + description: Either "release" or "rehearsal". Affects reporting only. + required: false + type: string + default: rehearsal + outputs: + version: + description: Package version of the verified candidate. + value: ${{ jobs.verify.outputs.version }} + release_tag: + description: Tag the verified candidate is bound to. + value: ${{ jobs.verify.outputs.release_tag }} + wheel_filename: + description: Basename of the qualified wheel. + value: ${{ jobs.verify.outputs.wheel_filename }} + wheel_sha256: + description: SHA-256 of the wheel that verification approved. + value: ${{ jobs.verify.outputs.wheel_sha256 }} + manifest_sha256: + description: >- + SHA-256 of the candidate manifest. Travels through the job-output + channel rather than the artifact store, so the publication job can + detect artifact substitution. + value: ${{ jobs.verify.outputs.manifest_sha256 }} + artifact_name: + description: Name of the uploaded candidate bundle. + value: ${{ jobs.verify.outputs.artifact_name }} + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + # Measured, not estimated: the correctness suite runs ~7 min wall clock at + # `-n auto` on a 2-core hosted runner, and build + qualification + audit + + # SBOM add ~4 min. 25 minutes leaves roughly 2x headroom on the dominant + # term. Re-derive this from the rehearsal timings printed in the readiness + # summary whenever the suite grows materially; see docs/release-runbook.md. + timeout-minutes: 25 + permissions: + contents: read + outputs: + version: ${{ steps.candidate.outputs.version }} + release_tag: ${{ steps.candidate.outputs.release_tag }} + wheel_filename: ${{ steps.qualified.outputs.wheel_filename }} + wheel_sha256: ${{ steps.handoff.outputs.wheel_sha256 }} + manifest_sha256: ${{ steps.handoff.outputs.manifest_sha256 }} + artifact_name: ${{ steps.handoff.outputs.artifact_name }} + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + - name: Resolve candidate version and tag + id: candidate + env: + REQUESTED_TAG: ${{ inputs.release_tag }} + run: | + set -euo pipefail + version="$(sed -n 's/^version = "\(.*\)"$/\1/p' pyproject.toml | head -n 1)" + if [ -z "${version}" ]; then + echo "::error::Could not read version from pyproject.toml." + exit 1 + fi + tag="${REQUESTED_TAG:-v${version}}" + # Fail fast before the test/qualification pipeline if the tag was cut + # on a commit whose pyproject.toml disagrees (e.g. a v0.15.0 tag + # pushed while main still says 0.14.0). PyPI uploads are immutable, + # so this is the last cheap moment to catch a mistagged release. + if [ "${tag}" != "v${version}" ]; then + echo "::error::Tag ${tag} does not match pyproject.toml version ${version}; refusing to release." + exit 1 + fi + echo "version=${version}" >> "${GITHUB_OUTPUT}" + echo "release_tag=${tag}" >> "${GITHUB_OUTPUT}" + echo "OK: ${tag} matches pyproject.toml (${version})." + + - name: Require protected safety qualification inputs + env: + QUALIFIED_WHEEL_URL: ${{ vars.SAFETY_QUALIFICATION_WHEEL_URL }} + QUALIFIED_WHEEL_FILENAME: ${{ vars.SAFETY_QUALIFICATION_WHEEL_FILENAME }} + QUALIFICATION_JSON_URL: ${{ vars.SAFETY_QUALIFICATION_JSON_URL }} + QUALIFICATION_BUNDLE_URL: ${{ vars.SAFETY_QUALIFICATION_SIGSTORE_BUNDLE_URL }} + QUALIFICATION_SIGNER_IDENTITY: ${{ vars.SAFETY_QUALIFICATION_SIGNER_IDENTITY }} + QUALIFICATION_OIDC_ISSUER: ${{ vars.SAFETY_QUALIFICATION_OIDC_ISSUER }} + run: | + missing=0 + for name in \ + QUALIFIED_WHEEL_URL \ + QUALIFIED_WHEEL_FILENAME \ + QUALIFICATION_JSON_URL \ + QUALIFICATION_BUNDLE_URL \ + QUALIFICATION_SIGNER_IDENTITY \ + QUALIFICATION_OIDC_ISSUER + do + if [ -z "${!name:-}" ]; then + echo "::error::Repository variable ${name} is required; refusing to release." + missing=1 + fi + done + if [ "${missing}" -ne 0 ]; then + exit 1 + fi + for url in \ + "${QUALIFIED_WHEEL_URL}" \ + "${QUALIFICATION_JSON_URL}" \ + "${QUALIFICATION_BUNDLE_URL}" + do + case "${url}" in + https://*) ;; + *) echo "::error::Qualification artifact URLs must use HTTPS."; exit 1 ;; + esac + done + case "${QUALIFIED_WHEEL_FILENAME}" in + *.whl) ;; + *) echo "::error::SAFETY_QUALIFICATION_WHEEL_FILENAME must be a wheel basename."; exit 1 ;; + esac + if [[ ! "${QUALIFIED_WHEEL_FILENAME}" =~ ^[A-Za-z0-9_.+-]+\.whl$ ]]; then + echo "::error::Qualified wheel filename contains unsafe characters or path components." + exit 1 + fi + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.12" + cache: pip + + - name: Install + run: python -m pip install -e ".[dev]" + + - name: Build a wheel from the checked-out source + # The provenance gate this feeds is the only thing tying the published + # bytes back to the tagged commit. PIP_CONSTRAINT pins the build + # backend so the archive is byte-reproducible; without it a hatchling + # bump alone changes the wheel and the gate fails on a legitimate + # release. See constraints/release-build.txt. + env: + PIP_CONSTRAINT: constraints/release-build.txt + run: | + set -euo pipefail + if [ -e source-build ] || [ -L source-build ]; then + echo "::error::source-build must not pre-exist in the release checkout." + exit 1 + fi + python -m build --wheel --outdir source-build . + + - name: Download configured qualification inputs + id: qualified + env: + QUALIFIED_WHEEL_URL: ${{ vars.SAFETY_QUALIFICATION_WHEEL_URL }} + QUALIFIED_WHEEL_FILENAME: ${{ vars.SAFETY_QUALIFICATION_WHEEL_FILENAME }} + QUALIFICATION_JSON_URL: ${{ vars.SAFETY_QUALIFICATION_JSON_URL }} + QUALIFICATION_BUNDLE_URL: ${{ vars.SAFETY_QUALIFICATION_SIGSTORE_BUNDLE_URL }} + run: | + set -euo pipefail + if [ -e qualified-dist ] || [ -L qualified-dist ]; then + echo "::error::qualified-dist must not pre-exist in the release checkout." + exit 1 + fi + mkdir -p qualified-dist + curl --fail --location --retry 3 --proto '=https' --proto-redir '=https' \ + --output "qualified-dist/${QUALIFIED_WHEEL_FILENAME}" \ + "${QUALIFIED_WHEEL_URL}" + curl --fail --location --retry 3 --proto '=https' --proto-redir '=https' \ + --output qualified-dist/safety-qualification.json \ + "${QUALIFICATION_JSON_URL}" + curl --fail --location --retry 3 --proto '=https' --proto-redir '=https' \ + --output qualified-dist/safety-qualification.sigstore.json \ + "${QUALIFICATION_BUNDLE_URL}" + echo "QUALIFIED_WHEEL=qualified-dist/${QUALIFIED_WHEEL_FILENAME}" >> "${GITHUB_ENV}" + echo "QUALIFIED_WHEEL_FILENAME=${QUALIFIED_WHEEL_FILENAME}" >> "${GITHUB_ENV}" + echo "wheel_filename=${QUALIFIED_WHEEL_FILENAME}" >> "${GITHUB_OUTPUT}" + + - name: Verify configured qualification signature + env: + QUALIFICATION_SIGNER_IDENTITY: ${{ vars.SAFETY_QUALIFICATION_SIGNER_IDENTITY }} + QUALIFICATION_OIDC_ISSUER: ${{ vars.SAFETY_QUALIFICATION_OIDC_ISSUER }} + run: | + sigstore verify identity \ + --bundle qualified-dist/safety-qualification.sigstore.json \ + --cert-identity "${QUALIFICATION_SIGNER_IDENTITY}" \ + --cert-oidc-issuer "${QUALIFICATION_OIDC_ISSUER}" \ + qualified-dist/safety-qualification.json + + - name: Verify production qualification and exact wheel binding + env: + RELEASE_TAG: ${{ steps.candidate.outputs.release_tag }} + run: | + python scripts/verify_safety_qualification_release.py \ + --wheel "${QUALIFIED_WHEEL}" \ + --qualification qualified-dist/safety-qualification.json \ + --tag "${RELEASE_TAG}" + python -m twine check "${QUALIFIED_WHEEL}" + + - name: Bind the qualified wheel to the tagged source tree + # Without this the pipeline tests the checkout and publishes a wheel + # downloaded from a repository variable, with nothing asserting the two + # correspond: any wheel declaring the right Name and Version passed. + # Byte equality is required; a container-metadata-only difference is + # reported as a reproducibility gap and still fails, because the + # weaker bar must never be taken silently. + run: | + python scripts/verify_wheel_provenance.py \ + --built source-build/*.whl \ + --qualified "${QUALIFIED_WHEEL}" \ + --source-commit "${GITHUB_SHA}" \ + --report provenance.json + + - name: Lint + run: python -m ruff check . + + - name: Compile + run: python -m compileall -q src tests + + - name: Verify generated schemas are up to date + run: python scripts/generate_schemas.py --check + + - name: Trust-model invariant lint (static, no user code execution) + # Kept as its own step, and excluded from the aggregate run below, so a + # release candidate that introduces a dynamic-import surface in an + # adapter fails with a named check rather than inside coverage noise. + run: python -m pytest tests/test_adapter_static_only.py -q + + - name: Test + # Deliberately differs from CI in two ways, and only two: + # + # * `-n auto` matches CI's supported parallelism. Serial execution + # made a release candidate spend the whole budget re-running work + # CI already parallelises. + # * `-m "not perf"` excludes the latency-budget tests. CI is the + # merge-time enforcement point for latency; those tests assert + # wallclock medians and are the one part of the suite that fails + # from shared-runner timing noise rather than from a defect. A + # release candidate must fail on deterministic correctness + # evidence, not on how busy the runner was. + # + # The coverage floor stays at CI's 85 so release cannot bypass it. + run: >- + python -m pytest -n auto -m "not perf" + --ignore=tests/test_adapter_static_only.py + --cov=agents_shipgate --cov-report=term-missing --cov-fail-under=85 + + - name: Dependency audit + run: python -m pip_audit . + + - name: Generate wheel-scoped SBOM + # Inventories an isolated runtime-only install of the qualified wheel. + # `cyclonedx-py environment` over `.[dev]` described the CI environment + # instead — pytest, ruff, twine, Sigstore and the CycloneDX tooling — + # so the signed SBOM attested to software the user never receives. + run: | + set -euo pipefail + mkdir -p candidate + python scripts/release_sbom.py build \ + --wheel "${QUALIFIED_WHEEL}" \ + --output candidate/agents-shipgate-sbom.json + python scripts/release_sbom.py verify \ + --wheel "${QUALIFIED_WHEEL}" \ + --sbom candidate/agents-shipgate-sbom.json + + - name: Assemble content-addressed candidate handoff + id: handoff + env: + RELEASE_TAG: ${{ steps.candidate.outputs.release_tag }} + run: | + set -euo pipefail + cp -- "${QUALIFIED_WHEEL}" "candidate/${QUALIFIED_WHEEL_FILENAME}" + cp -- qualified-dist/safety-qualification.json candidate/safety-qualification.json + cp -- qualified-dist/safety-qualification.sigstore.json \ + candidate/safety-qualification.sigstore.json + cp -- provenance.json candidate/provenance.json + cmp -- "${QUALIFIED_WHEEL}" "candidate/${QUALIFIED_WHEEL_FILENAME}" + python scripts/release_publication.py manifest \ + --tag "${RELEASE_TAG}" \ + --source-commit "${GITHUB_SHA}" \ + --wheel "candidate/${QUALIFIED_WHEEL_FILENAME}" \ + --asset candidate/agents-shipgate-sbom.json \ + --asset candidate/safety-qualification.json \ + --asset candidate/safety-qualification.sigstore.json \ + --asset candidate/provenance.json \ + --output candidate/candidate-manifest.json + manifest_sha256="$(python -c 'import hashlib,pathlib,sys; sys.stdout.write(hashlib.sha256(pathlib.Path("candidate/candidate-manifest.json").read_bytes()).hexdigest())')" + wheel_sha256="$(python -c 'import hashlib,pathlib,os,sys; sys.stdout.write(hashlib.sha256(pathlib.Path("candidate/"+os.environ["QUALIFIED_WHEEL_FILENAME"]).read_bytes()).hexdigest())')" + { + echo "manifest_sha256=${manifest_sha256}" + echo "wheel_sha256=${wheel_sha256}" + echo "artifact_name=release-candidate-${RELEASE_TAG}" + } >> "${GITHUB_OUTPUT}" + + - name: Upload candidate bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: ${{ steps.handoff.outputs.artifact_name }} + path: candidate/ + if-no-files-found: error + retention-days: 14 + + - name: Readiness summary + env: + MODE: ${{ inputs.mode }} + RELEASE_TAG: ${{ steps.candidate.outputs.release_tag }} + WHEEL_FILENAME: ${{ steps.qualified.outputs.wheel_filename }} + WHEEL_SHA256: ${{ steps.handoff.outputs.wheel_sha256 }} + MANIFEST_SHA256: ${{ steps.handoff.outputs.manifest_sha256 }} + run: | + { + echo "## Release candidate ${RELEASE_TAG} (${MODE})" + echo + echo "| Control | Result |" + echo "| --- | --- |" + echo "| Tag matches pyproject version | pass |" + echo "| Qualification signature identity | pass |" + echo "| Production qualification policy | pass |" + echo "| Wheel bound to tagged source | $(python -c 'import json,sys; sys.stdout.write(json.load(open("provenance.json"))["provenance_mode"])') |" + echo "| Correctness suite (-n auto, not perf) | pass |" + echo "| Dependency audit | pass |" + echo "| Wheel-scoped SBOM | pass |" + echo + echo "- Wheel: \`${WHEEL_FILENAME}\`" + echo "- Wheel SHA-256: \`${WHEEL_SHA256}\`" + echo "- Candidate manifest SHA-256: \`${MANIFEST_SHA256}\`" + echo + if [ "${MODE}" = "rehearsal" ]; then + echo "Rehearsal only. This workflow holds no publication authority:" + echo "no PyPI upload, no tag, and no GitHub Release was created." + else + echo "Verification complete. Publication requires \`pypi\` environment approval." + fi + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c99c88f7..a5b0bf8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,78 +5,50 @@ on: tags: - "v*" -permissions: - contents: write - id-token: write +# No ambient authority. Each job requests the minimum it needs: verification +# gets read-only via the reusable workflow, and only `publish` holds the +# `contents: write` and `id-token: write` that can mutate anything outside +# this run. +permissions: {} + +# Serialised across the PyPI project rather than per tag. Two different tags +# racing to publish the same distribution is exactly the case a per-tag group +# would allow. `cancel-in-progress: false` because a half-finished publication +# transaction must be allowed to reach its finalisation step; cancelling it is +# how PyPI ends up holding a version with no corresponding GitHub Release. +concurrency: + group: release-publish-agents-shipgate + cancel-in-progress: false jobs: - release: + verify: + name: Verify candidate + permissions: + contents: read + uses: ./.github/workflows/release-verify.yml + with: + ref: ${{ github.ref }} + release_tag: ${{ github.ref_name }} + mode: release + + publish: + name: Publish candidate + needs: verify runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 + # The human gate sits here rather than on verification. Reviewers now + # approve *after* the readiness summary exists, instead of approving a run + # whose evidence has not been produced yet. environment: pypi + permissions: + contents: write + id-token: write steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - - name: Verify tag matches package version - # Fail fast before the test/qualification/publish pipeline if the tag - # was cut on a commit whose pyproject.toml disagrees (e.g. a - # v0.15.0 tag pushed while main still says 0.14.0). PyPI - # uploads are immutable, so this is the last cheap moment to - # catch a mistagged release. - run: | - version="$(sed -n 's/^version = "\(.*\)"$/\1/p' pyproject.toml | head -n 1)" - if [ "${GITHUB_REF_NAME}" != "v${version}" ]; then - echo "::error::Tag ${GITHUB_REF_NAME} does not match pyproject.toml version ${version}; refusing to release." - exit 1 - fi - echo "OK: ${GITHUB_REF_NAME} matches pyproject.toml (${version})." - - - name: Require protected safety qualification inputs - env: - QUALIFIED_WHEEL_URL: ${{ vars.SAFETY_QUALIFICATION_WHEEL_URL }} - QUALIFIED_WHEEL_FILENAME: ${{ vars.SAFETY_QUALIFICATION_WHEEL_FILENAME }} - QUALIFICATION_JSON_URL: ${{ vars.SAFETY_QUALIFICATION_JSON_URL }} - QUALIFICATION_BUNDLE_URL: ${{ vars.SAFETY_QUALIFICATION_SIGSTORE_BUNDLE_URL }} - QUALIFICATION_SIGNER_IDENTITY: ${{ vars.SAFETY_QUALIFICATION_SIGNER_IDENTITY }} - QUALIFICATION_OIDC_ISSUER: ${{ vars.SAFETY_QUALIFICATION_OIDC_ISSUER }} - run: | - missing=0 - for name in \ - QUALIFIED_WHEEL_URL \ - QUALIFIED_WHEEL_FILENAME \ - QUALIFICATION_JSON_URL \ - QUALIFICATION_BUNDLE_URL \ - QUALIFICATION_SIGNER_IDENTITY \ - QUALIFICATION_OIDC_ISSUER - do - if [ -z "${!name:-}" ]; then - echo "::error::Protected pypi environment variable ${name} is required; refusing to release." - missing=1 - fi - done - if [ "${missing}" -ne 0 ]; then - exit 1 - fi - for url in \ - "${QUALIFIED_WHEEL_URL}" \ - "${QUALIFICATION_JSON_URL}" \ - "${QUALIFICATION_BUNDLE_URL}" - do - case "${url}" in - https://*) ;; - *) echo "::error::Qualification artifact URLs must use HTTPS."; exit 1 ;; - esac - done - case "${QUALIFIED_WHEEL_FILENAME}" in - *.whl) ;; - *) echo "::error::SAFETY_QUALIFICATION_WHEEL_FILENAME must be a wheel basename."; exit 1 ;; - esac - if [[ ! "${QUALIFIED_WHEEL_FILENAME}" =~ ^[A-Za-z0-9_.+-]+\.whl$ ]]; then - echo "::error::Qualified wheel filename contains unsafe characters or path components." - exit 1 - fi + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 @@ -84,92 +56,123 @@ jobs: python-version: "3.12" cache: pip - - name: Install + - name: Install publication tooling run: | python -m pip install -e ".[dev]" python -m pip install "uv==0.11.7" - - name: Download configured qualification inputs + - name: Download verified candidate + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: ${{ needs.verify.outputs.artifact_name }} + path: dist + + - name: Verify the candidate handoff + # The expected digest arrives through the job-output channel, not the + # artifact store, so swapping a file in the store — or rewriting the + # manifest to agree with the swap — breaks one of the links. env: - QUALIFIED_WHEEL_URL: ${{ vars.SAFETY_QUALIFICATION_WHEEL_URL }} - QUALIFIED_WHEEL_FILENAME: ${{ vars.SAFETY_QUALIFICATION_WHEEL_FILENAME }} - QUALIFICATION_JSON_URL: ${{ vars.SAFETY_QUALIFICATION_JSON_URL }} - QUALIFICATION_BUNDLE_URL: ${{ vars.SAFETY_QUALIFICATION_SIGSTORE_BUNDLE_URL }} + MANIFEST_SHA256: ${{ needs.verify.outputs.manifest_sha256 }} run: | - if [ -e qualified-dist ] || [ -L qualified-dist ]; then - echo "::error::qualified-dist must not pre-exist in the release checkout." - exit 1 - fi - mkdir -p qualified-dist - curl --fail --location --retry 3 --proto '=https' --proto-redir '=https' \ - --output "qualified-dist/${QUALIFIED_WHEEL_FILENAME}" \ - "${QUALIFIED_WHEEL_URL}" - curl --fail --location --retry 3 --proto '=https' --proto-redir '=https' \ - --output qualified-dist/safety-qualification.json \ - "${QUALIFICATION_JSON_URL}" - curl --fail --location --retry 3 --proto '=https' --proto-redir '=https' \ - --output qualified-dist/safety-qualification.sigstore.json \ - "${QUALIFICATION_BUNDLE_URL}" - echo "QUALIFIED_WHEEL=qualified-dist/${QUALIFIED_WHEEL_FILENAME}" >> "${GITHUB_ENV}" - echo "QUALIFIED_WHEEL_FILENAME=${QUALIFIED_WHEEL_FILENAME}" >> "${GITHUB_ENV}" - - - name: Verify configured qualification signature + python scripts/release_publication.py verify-manifest \ + --manifest dist/candidate-manifest.json \ + --expected-sha256 "${MANIFEST_SHA256}" + + - name: Re-verify the wheel-scoped SBOM binding env: - QUALIFICATION_SIGNER_IDENTITY: ${{ vars.SAFETY_QUALIFICATION_SIGNER_IDENTITY }} - QUALIFICATION_OIDC_ISSUER: ${{ vars.SAFETY_QUALIFICATION_OIDC_ISSUER }} + WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} run: | - sigstore verify identity \ - --bundle qualified-dist/safety-qualification.sigstore.json \ - --cert-identity "${QUALIFICATION_SIGNER_IDENTITY}" \ - --cert-oidc-issuer "${QUALIFICATION_OIDC_ISSUER}" \ - qualified-dist/safety-qualification.json + python scripts/release_sbom.py verify \ + --wheel "dist/${WHEEL_FILENAME}" \ + --sbom dist/agents-shipgate-sbom.json - - name: Verify production qualification and exact wheel binding - run: | - python scripts/verify_safety_qualification_release.py \ - --wheel "${QUALIFIED_WHEEL}" \ - --qualification qualified-dist/safety-qualification.json \ - --tag "${GITHUB_REF_NAME}" - python -m twine check "${QUALIFIED_WHEEL}" - - - name: Lint and test - # v0.21 (E7): coverage threshold bumped from 75 → 85, matching - # CI gate so release cannot bypass the tighter floor. + - name: Sign release artifacts + env: + WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} run: | - python -m ruff check . - python -m compileall -q src tests - python -m pytest --cov=agents_shipgate --cov-report=term-missing --cov-fail-under=85 - - - name: Dependency audit - run: python -m pip_audit . + sigstore sign --output-directory dist --overwrite \ + "dist/${WHEEL_FILENAME}" \ + dist/agents-shipgate-sbom.json - - name: Generate SBOM + - name: Create draft GitHub release + # Created before the immutable PyPI upload and carrying the + # authoritative assets, so a failure anywhere after publication leaves + # a discoverable draft to finish from rather than a published version + # with nothing attached. + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} run: | - if [ -e dist ] || [ -L dist ]; then - echo "::error::dist must not pre-exist before qualified artifact promotion." - exit 1 + set -euo pipefail + # `v1.2.3` is a final release; anything else (v0.16.0b7, rc, a) is a + # pre-release and must not be promoted to "Latest". + if [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + maturity="--latest" + else + maturity="--prerelease" + fi + echo "RELEASE_MATURITY=${maturity}" >> "${GITHUB_ENV}" + if gh release view "${RELEASE_TAG}" > /dev/null 2>&1; then + echo "Release ${RELEASE_TAG} already exists; re-uploading assets to the existing draft." + gh release upload "${RELEASE_TAG}" dist/* --clobber + else + gh release create "${RELEASE_TAG}" dist/* \ + --draft \ + "${maturity}" \ + --title "${RELEASE_TAG}" \ + --notes "Agents Shipgate ${RELEASE_TAG}" fi - mkdir -p dist - cp -- "${QUALIFIED_WHEEL}" "dist/${QUALIFIED_WHEEL_FILENAME}" - cp -- qualified-dist/safety-qualification.json dist/safety-qualification.json - cp -- qualified-dist/safety-qualification.sigstore.json \ - dist/safety-qualification.sigstore.json - cmp -- "${QUALIFIED_WHEEL}" "dist/${QUALIFIED_WHEEL_FILENAME}" - cyclonedx-py environment --pyproject pyproject.toml -o dist/agents-shipgate-sbom.json - - name: Sign release artifacts + - name: Classify index state + id: index + env: + WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} run: | - sigstore sign --output-directory dist --overwrite \ - "dist/${QUALIFIED_WHEEL_FILENAME}" \ - dist/agents-shipgate-sbom.json + python scripts/release_publication.py pypi-state \ + --wheel "dist/${WHEEL_FILENAME}" \ + --github-output "${GITHUB_OUTPUT}" - name: Publish to PyPI with Trusted Publishing - run: uv publish --trusted-publishing always "dist/${QUALIFIED_WHEEL_FILENAME}" + # Skipped when the index already holds these exact bytes, which is what + # a re-run after a post-publication failure looks like. The state check + # hard-fails if the version exists with *different* bytes, so this can + # never quietly succeed against a divergent artifact. + if: steps.index.outputs.should_publish == 'true' + env: + WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} + run: uv publish --trusted-publishing always "dist/${WHEEL_FILENAME}" - - name: Create GitHub release + - name: Confirm the index holds the verified bytes + env: + WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} + run: | + python scripts/release_publication.py pypi-state \ + --wheel "dist/${WHEEL_FILENAME}" | tee /dev/stderr | grep -q "published_identical" + + - name: Validate release assets env: GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} + WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} run: | - gh release create "${GITHUB_REF_NAME}" dist/* \ - --title "${GITHUB_REF_NAME}" \ - --notes "Agents Shipgate ${GITHUB_REF_NAME}" + set -euo pipefail + attached="$(gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name')" + for required in \ + "${WHEEL_FILENAME}" \ + agents-shipgate-sbom.json \ + safety-qualification.json \ + provenance.json \ + candidate-manifest.json + do + if ! printf '%s\n' "${attached}" | grep -qx -- "${required}"; then + echo "::error::Release ${RELEASE_TAG} is missing required asset ${required}." + exit 1 + fi + done + echo "OK: all required assets are attached to ${RELEASE_TAG}." + + - name: Finalise GitHub release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} + run: gh release edit "${RELEASE_TAG}" --draft=false "${RELEASE_MATURITY}" diff --git a/CHANGELOG.md b/CHANGELOG.md index e429e0e0..20eddbdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,75 @@ ## Unreleased +- **The release pipeline now proves the wheel it publishes came from the + tagged commit.** The tag workflow established three bindings — tag to + `pyproject.toml` version, qualification payload to wheel bytes, and tag to + the wheel's own `METADATA` version — but none tied the shipped bytes back to + any source tree. It tested the checkout with `ruff`, `compileall`, and + `pytest`, then published a wheel downloaded from a repository-variable URL, + with nothing asserting the two corresponded: any wheel declaring + `Name: agents-shipgate` and the right `Version` satisfied every check. + Verification now rebuilds the wheel from the tagged checkout and requires + byte equality with the qualified wheel before publication + (`scripts/verify_wheel_provenance.py`). Byte equality is achievable because + the build backend is pinned in `constraints/release-build.txt` — wheels + record `Generator: hatchling `, so an unpinned backend alone + changes the archive. A container-metadata-only difference is reported as a + reproducibility gap and still fails; the weaker unpacked-content bar exists + behind an explicit `--allow-payload-equivalent` flag so it can never be + taken silently. The published artifact is still the signed, qualified wheel; + the rebuilt one is only a comparison reference. +- **Verification and publication are now separate jobs, and a partial publish + is recoverable.** Expensive verification and immutable publication ran in + one job, so a failure after a successful PyPI upload could leave an + immutable version with no finalized GitHub Release and no attached + provenance — and re-running was not a safe recovery, because the version + already existed. Verification is now a read-only reusable workflow + (`contents: read`, no OIDC) that hands off a content-addressed candidate + bundle; the manifest digest travels through the job-output channel, so + swapping an artifact — or rewriting the manifest to agree with the swap — is + detected. Publication holds the only `contents: write` and `id-token: write`, + creates a **draft** GitHub Release carrying the authoritative assets before + uploading to PyPI, and finalizes only after asset validation. The upload is + idempotence-aware: `scripts/release_publication.py pypi-state` classifies the + index as `absent`, `published_identical` (a re-run completing an interrupted + transaction, which skips the upload), or `published_divergent` (always + fatal). An unreachable index is never read as permission to upload. Release + concurrency is serialized across the PyPI project rather than per tag, with + `cancel-in-progress: false`. The `pypi` reviewer gate moved to publication, + so reviewers approve *after* the readiness summary exists instead of + approving a run whose evidence has not been produced yet. +- **The signed SBOM now describes the shipped wheel instead of the CI + machine.** The workflow installed `.[dev]` and ran `cyclonedx-py + environment`, inventorying pytest, ruff, twine, Sigstore, and the CycloneDX + tooling itself — a signed attestation about software the user never + receives. `scripts/release_sbom.py` inventories an isolated, runtime-only + install of the qualified wheel, binds the document to that wheel's SHA-256, + and re-verifies the binding before publication. It also normalizes away the + `file://` build-machine path CycloneDX records, which otherwise leaked runner + filesystem layout into a published artifact and made the signed SBOM + non-deterministic. The dev-only exclusion is derived from the `dev` extra + rather than hardcoded, so new tooling is covered automatically. +- **A release candidate can be rehearsed without any publication authority.** + The workflow could only be exercised by pushing a `v*` tag, so its + verification and failure paths were first-run at the moment publication + became possible — steps added after v0.15.0 had never executed. A + `workflow_dispatch` rehearsal now runs the identical build, qualification, + test, audit, SBOM, and handoff path by calling the same reusable workflow, + and is structurally incapable of publishing: no publication job exists in the + file, `permissions: contents: read` caps the token so tag and release + creation fail, and no `id-token: write` means Trusted Publishing cannot mint + a token. Rehearsal is a documented prerequisite for a candidate tag. +- **Release test selection matches CI, so candidates fail on correctness + evidence rather than timing noise.** The release ran the full suite serially, + including timing-sensitive `perf` tests, inside a 15-minute budget shared + with qualification, audit, signing, and artifact work. It now uses CI's + `-n auto` parallelism and excludes `perf`-marked latency budgets, which + remain enforced at merge time; the adapter static-only trust-model lint keeps + its own fail-fast step, and the coverage floor stays at CI's 85. The timeout + is derived from measurement rather than estimate, with the basis recorded in + `docs/release-runbook.md`. + - **Human review now blocks merge and completion, not publication of the evidence a human needs in order to review.** A human route was one universal stop: `control.state: "human_review_required"` with `must_stop: true` and diff --git a/constraints/release-build.txt b/constraints/release-build.txt new file mode 100644 index 00000000..669a8bb7 --- /dev/null +++ b/constraints/release-build.txt @@ -0,0 +1,21 @@ +# Pinned build backend for reproducible release wheels. +# +# Wheels record `Generator: hatchling ` in `.dist-info/WHEEL`, so the +# backend version is part of the archive's content. With the unpinned +# `requires = ["hatchling>=1.31.0"]` build requirement in pyproject.toml, two +# machines that resolve different hatchling versions produce different bytes +# for identical source — which would make byte-for-byte reproducibility +# impossible and turn the source-to-wheel binding in +# scripts/verify_wheel_provenance.py into a spurious release blocker. +# +# Both sides of that comparison must build with this file: +# +# PIP_CONSTRAINT=constraints/release-build.txt python -m build --wheel +# +# that is, the qualification promotion flow that produces the signed, +# qualified wheel *and* the release verification job that rebuilds from the +# tagged source. See docs/release-runbook.md. +# +# Bumping this pin changes the wheel bytes. A qualified wheel produced before +# the bump will no longer match, so re-run qualification after any change here. +hatchling==1.31.0 diff --git a/docs/INDEX.md b/docs/INDEX.md index 6c45dd8d..d400c2db 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -160,6 +160,8 @@ A single entry point for human readers and AI agents walking the `docs/` tree. - [`integrations.md`](integrations.md) — CI/CD integration recipes (GitHub Actions, GitLab CI, CircleCI, Jenkins snippet) - [`troubleshooting.md`](troubleshooting.md) — error messages → fixes - [`distribution.md`](distribution.md) — release process and SBOM/signature verification +- [`release-runbook.md`](release-runbook.md) — cutting a tag: mandatory rehearsal, the two-job publication transaction, provenance bindings, and the recovery path when PyPI succeeds but finalization fails +- [`release-evidence-policy-decision.md`](release-evidence-policy-decision.md) — open decision brief for the pre-1.0 qualification evidence bar (awaiting a named human owner; no route selected) - [`decisions.md`](decisions.md) — architectural decisions ## For agents diff --git a/docs/distribution.md b/docs/distribution.md index 7aa6093c..1d238011 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -18,24 +18,35 @@ to install a published PyPI version. ## Supply Chain -- Generate SBOMs for release artifacts. +- Generate a wheel-scoped SBOM from an isolated runtime-only install of the + published wheel, bound to its SHA-256. - Sign release artifacts with Sigstore. - Publish to PyPI through Trusted Publishing from `.github/workflows/release.yml`. - Keep GitHub Actions pinned by SHA. +- Pin the build backend in `constraints/release-build.txt` so release wheels are + byte-reproducible. - Use Dependabot for Python and GitHub Actions updates. - Add a lockfile for release and dev dependency builds once packaging workflow is finalized. PyPI Trusted Publishing is configured for this repository's tag-triggered release workflow and protected `pypi` environment. +The operational procedure — mandatory rehearsal, the two-job publication +transaction, and the recovery path when PyPI succeeds but release finalization +fails — is in [`release-runbook.md`](release-runbook.md). + ### Protected qualification inputs -Every tag release now fails closed unless the protected `pypi` environment -provides all six variables below. Values must be populated by the independent -benchmark-owner promotion flow after it runs the frozen corpus against the -exact wheel and signs `safety-qualification.json`: +Every tag release fails closed unless all six variables below are configured. +They are read by the read-only verification job, which runs without an +environment so it can run unattended and produce the evidence the `pypi` +reviewers approve against — so the values must exist at **repository** scope. +Environment-scoped values still override them for the publication job. Values +must be populated by the independent benchmark-owner promotion flow after it +runs the frozen corpus against the exact wheel and signs +`safety-qualification.json`: -| Environment variable | Required value | +| Variable | Required value | |---|---| | `SAFETY_QUALIFICATION_WHEEL_URL` | HTTPS URL for the exact qualified wheel | | `SAFETY_QUALIFICATION_WHEEL_FILENAME` | Safe wheel basename, for example `agents_shipgate-0.16.0b6-py3-none-any.whl` | @@ -44,13 +55,20 @@ exact wheel and signs `safety-qualification.json`: | `SAFETY_QUALIFICATION_SIGNER_IDENTITY` | Exact trusted certificate identity configured for qualification promotion | | `SAFETY_QUALIFICATION_OIDC_ISSUER` | Trusted OIDC issuer, normally `https://token.actions.githubusercontent.com` for GitHub Actions | -The release workflow verifies the signature identity first, then validates -the artifact's production policy, 100-case invariants, tag/version, and wheel -SHA-256. It copies and publishes that exact wheel only; it never rebuilds the -package after qualification. Missing variables, non-HTTPS URLs, an unsafe -filename, an invalid signature, a non-production result, or any binding -mismatch stops before PyPI publication. Protect variable updates with required -environment reviewers who are independent of the release initiator. +The verification job checks the signature identity first, then validates the +artifact's production policy, 100-case invariants, tag/version, and wheel +SHA-256. It then rebuilds a wheel from the tagged checkout and requires byte +equality with the qualified wheel, which is the binding that ties the published +artifact to the tagged commit. The rebuilt wheel is only ever a comparison +reference: the artifact published to PyPI remains the exact qualified wheel. + +Missing variables, non-HTTPS URLs, an unsafe filename, an invalid signature, a +non-production result, or any binding mismatch stops before PyPI publication. + +Because a tampered wheel URL now fails the source-binding gate rather than +reaching PyPI, variable ACLs are no longer the primary control over what gets +published. The required-reviewer gate on the `pypi` environment protects the +publication step itself. This is a configured trust root, not proof of organizational independence. The promotion job trusts the signed qualification summary and does not replay diff --git a/docs/release-evidence-policy-decision.md b/docs/release-evidence-policy-decision.md new file mode 100644 index 00000000..800518f2 --- /dev/null +++ b/docs/release-evidence-policy-decision.md @@ -0,0 +1,118 @@ +# Decision Brief: The Evidence Bar for Pre-1.0 Tags + +**Status: open. Awaiting a named human product/security owner.** +Tracked by [#341](https://github.com/ThreeMoonsLab/agents-shipgate/issues/341). + +This brief exists to make the decision cheap to take, not to take it. No route +is selected here, and nothing in this document changes the enforced policy: the +release verifier still requires the full 100-case production-beta artifact. + +Do not infer the choice from the tag, and do not rename the current policy after +the fact. + +## Why a decision is required + +`v0.16.0` is the first tag that will run the safety-qualification gate +end to end. The gate was added after `v0.15.0`, so it has never executed as a +release precondition. + +The bar it enforces cannot currently be met: + +| Requirement | Enforced today | Available today | +|---|---|---| +| Adjudicated cases | 100, across 28 profile × decision strata | 32 labelled rows | +| Qualifying origins | ≥ 40 real-history / rejected-or-reverted / design-partner | not separately tracked | +| Per-case verifier receipt | required, unique digest per case | not produced by the miner corpus | +| Holdout per stratum | enforced fraction per stratum | n/a | +| Label agreement | Cohen's κ floor | n/a | + +The 32 rows under `benchmark/miner/results/*.labels.csv` are a useful product +measurement, but they are not interchangeable with qualification cases: +qualification additionally binds adjudicated labels and a terminal verifier +receipt per case. + +So `v0.16.0` is blocked on a policy question, not on engineering. The five +engineering workstreams (#342, #343, #344, #355, #356) are complete and +independent of this decision. + +## Where the bar is defined + +Changing the bar means changing all of these together; they are cross-checked, +so a partial change fails the release verifier rather than silently weakening +it. + +- `production_safety_requirements()` in + `src/agents_shipgate/schemas/safety_qualification.py` — case counts, strata, + origin minimum, κ floor, holdout fraction, unsafe-auto-pass maximum. +- `QualificationTier` — distinguishes `beta` from `test`. There is **no latent, + already-approved "production tier"** to select at 1.0. +- `scripts/verify_safety_qualification_release.py` — requires + `qualification_tier == "beta"` and `production_qualified == true`, and + re-derives every count, interval, and confusion matrix. +- `scripts/run_safety_qualification.py` — produces the artifact. +- `docs/distribution.md` — describes the 100-case artifact as the protected + release input. + +## The two routes + +### Route 1 — retain the 100-case production-beta policy + +`v0.16.0` stays blocked until a separate corpus-delivery issue produces the +independently labelled, adjudicated, receipt-bound artifact. + +- **Cost:** the release is gated on a substantial data effort — roughly 68 more + adjudicated cases with receipts, spread to satisfy 28 strata and the origin + minimum. +- **Benefit:** the shipped claim is exactly the claim that was designed. No + vocabulary churn, no promotion path to maintain. +- **Implementation:** none. Record the decision, open the corpus issue. + +### Route 2 — approve an explicit pre-1.0 policy + +Define a **new, separately named** versioned policy governing `0.x` tags, with +reduced evidence coverage and an explicit promotion path to the 100-case bar +at 1.0. + +- **Cost:** a new tier in the schema vocabulary, a second requirements + constructor, verifier branching, and doc updates. Adds a surface that must + later be retired. +- **Benefit:** unblocks `v0.16.0` on a stated, auditable basis rather than an + implied one. +- **Non-negotiable regardless of the numbers chosen:** + - zero unsafe auto-passes, per profile and overall + (`maximum_unsafe_auto_passes == 0`); + - a per-case terminal verifier receipt, with unique digests; + - a holdout fraction per stratum; + - `static_only == true` and `runtime_behavior_proven == false`; + - internal consistency re-derived by the verifier, never trusted from the + artifact. + + A "smaller corpus" may reduce **coverage**. It must not reduce **strictness**. + +If Route 2 is chosen, the decision must state: case count, strata layout, origin +minimum, κ floor, holdout fraction, which tags the policy governs, and what +promotion to the 1.0 bar requires. Implementation follows in a separate issue — +this decision does not silently implement a lower bar. + +## Acceptance for whichever route is chosen + +- [ ] A named human product/security owner records the route and rationale. +- [ ] Case counts, origin counts, agreement threshold, holdout rule, and the + zero-unsafe-pass invariant are explicit. +- [ ] The policy states which versions/tags it governs and how promotion works. +- [ ] Documentation, schema vocabulary, qualification generator, release + verifier, and this runbook agree. +- [ ] A separate implementation/corpus-delivery issue is opened. +- [ ] A rehearsal ([`release-runbook.md`](release-runbook.md)) proves the chosen + policy **fails closed** — run it against an artifact that misses the bar + and confirm publication stops at the qualification step. + +## What is already true regardless + +The other five release-integrity controls do not depend on this decision and are +in place: source-to-wheel binding, separated verification and publication with a +recoverable transaction, deterministic test selection with a measured timeout, a +non-publishing rehearsal path, and a wheel-scoped signed SBOM. + +Whichever bar is approved, it will be enforced by a pipeline that also proves +the bytes it publishes came from the tagged commit. diff --git a/docs/release-runbook.md b/docs/release-runbook.md new file mode 100644 index 00000000..164a3338 --- /dev/null +++ b/docs/release-runbook.md @@ -0,0 +1,187 @@ +# Release Runbook + +Operational procedure for cutting a tagged release. Covers what each control +proves, the mandatory rehearsal, and the recovery path when publication +partially succeeds. + +For the packaging surface and post-release fan-out checks, see +[`distribution.md`](distribution.md). + +## The pipeline + +A release runs as two jobs with an explicit, content-addressed handoff. + +| Job | Workflow | Permissions | What it does | +|---|---|---|---| +| `verify` | `release-verify.yml` (reusable) | `contents: read` | Builds from the tagged source, validates the signed qualification, binds wheel to source, runs the correctness suite, audits dependencies, produces the wheel-scoped SBOM, uploads a candidate bundle | +| `publish` | `release.yml` | `contents: write`, `id-token: write`, `environment: pypi` | Re-derives every digest, signs, drafts the GitHub Release, publishes to PyPI once, validates assets, finalises | + +Verification holds no write or OIDC authority, so the expensive read-only work +cannot mutate anything. The `pypi` environment's required-reviewer gate sits on +`publish` alone, which means reviewers approve **after** the readiness summary +exists rather than approving a run whose evidence has not been produced yet. + +### Which artifact is authoritative + +The **qualified wheel** — the one named by `SAFETY_QUALIFICATION_WHEEL_FILENAME` +and covered by the signed `safety-qualification.json` — is what ships. The wheel +built during verification is never published; it exists only to prove the +qualified wheel came from the tagged commit. + +Provenance is established by four bindings, all before `uv publish`: + +1. **tag ↔ source** — the tag must equal `v` at the checkout. +2. **qualification ↔ wheel bytes** — the signed artifact records the wheel's + SHA-256, and the Sigstore identity is verified before the JSON is parsed. +3. **tag ↔ wheel version** — from the wheel's own `METADATA`. +4. **source ↔ wheel** — `scripts/verify_wheel_provenance.py` rebuilds from the + tagged checkout and requires byte equality. + +Binding 4 is the one that was missing. Without it, any wheel declaring +`Name: agents-shipgate` and the right `Version` satisfied every check, so the +pipeline tested one artifact and published another. + +### Build reproducibility + +Byte equality is only achievable because the build backend is pinned in +[`constraints/release-build.txt`](../constraints/release-build.txt). Wheels +record `Generator: hatchling ` inside `.dist-info/WHEEL`, so an +unpinned backend makes two machines produce different bytes from identical +source. + +**The qualification promotion flow must build with the same constraint file:** + +```bash +PIP_CONSTRAINT=constraints/release-build.txt python -m build --wheel +``` + +If a backend bump lands between qualification and release, the provenance gate +fails. The fix is to re-run qualification against a wheel built with the current +pin — not to relax the comparison. `--allow-payload-equivalent` exists as a +pre-approved interim control for genuine reproducibility gaps; using it requires +opening an issue to track the gap, and it still rejects any content difference. + +## Before tagging: rehearse + +**A rehearsal on the candidate commit is a prerequisite for pushing a tag.** +Before this existed, the verification and failure paths of the release workflow +were first-run at the same moment publication became possible. + +Run the **Release Rehearsal** workflow (`workflow_dispatch`) against the +candidate ref. It calls the same reusable verification workflow the release +uses — same build, qualification validation, tests, audit, SBOM, and handoff. + +It cannot publish, for three independent reasons: there is no publication job in +the file, `permissions: contents: read` caps the token so tag and release +creation fail, and no `id-token: write` anywhere means Trusted Publishing cannot +mint a token. + +Check the run's readiness summary before tagging: + +- every control row reads `pass`; +- **Wheel bound to tagged source** reads `identical_bytes` (`identical_payload` + means the backend pin drifted); +- the wheel SHA-256 matches the wheel you expect to ship. + +### Rehearsing a failure path + +At least once per release-process change, prove the gate fails closed. The +cheapest deliberate mismatch: point `SAFETY_QUALIFICATION_WHEEL_URL` at a wheel +from a different commit and confirm the rehearsal fails at **Bind the qualified +wheel to the tagged source tree**, naming the differing members. Restore the +variable afterwards. + +### Re-deriving the timeout + +`release-verify.yml` sets `timeout-minutes: 25`. The dominant term is the +correctness suite (~710s of CPU work; roughly 6 minutes wall clock at `-n auto` +on a 2-core hosted runner), plus ~4 minutes for build, qualification, audit and +SBOM. That leaves about 2x headroom. + +After any change that materially grows the suite, read the actual job duration +from a rehearsal run and reset the timeout to roughly twice it. Do not raise it +in response to a single timeout without checking what got slower. + +## Cutting the release + +1. Confirm `pyproject.toml` has the release version and a rehearsal is green. +2. Push the tag: `git tag v0.16.0 && git push origin v0.16.0`. +3. The `verify` job runs unattended. +4. Approve the `pypi` environment gate on the `publish` job, using the readiness + summary as the evidence. +5. Confirm the GitHub Release is published (not draft) with all assets, then run + the fan-out checks in [`distribution.md`](distribution.md). + +## Recovery + +PyPI uploads are **immutable**. A version can never be replaced, so recovery is +about completing an interrupted transaction, never about retrying it blindly. + +The publication job is ordered so that the recoverable state is the likely one: +the draft GitHub Release, carrying every authoritative asset, is created +*before* the PyPI upload. + +### Publication succeeded, finalisation failed + +This is the case the ordering is designed for. PyPI holds the version and a +**draft** GitHub Release holds the wheel, SBOM, signatures, qualification +artifacts, provenance record, and candidate manifest. + +Re-run the `publish` job. It is idempotence-aware: +`scripts/release_publication.py pypi-state` classifies the index as +`published_identical`, the upload step is skipped via its `if:` condition, and +the job proceeds to asset validation and finalisation. + +If re-running is not possible, finalise by hand — the draft already has the +authoritative assets: + +```bash +gh release edit v0.16.0 --draft=false --latest +``` + +### Publication failed + +Nothing was uploaded. Fix the cause and re-run the `publish` job; the state +check returns `absent` and the upload proceeds normally. + +### The index holds different bytes for this version + +`pypi-state` exits non-zero with `published_divergent` and publication stops. +This means the version was uploaded from a different artifact — possibly a +partially-completed earlier attempt with a different wheel. + +**Do not attempt to republish; PyPI will not accept it.** Cut a new patch or +pre-release version, re-run qualification against the new wheel, and tag again. +Delete or clearly mark the stale draft release so the wrong assets are not +mistaken for the shipped ones. + +### Verification failed + +Nothing outside the run changed: no tag deletion, no cleanup needed. Fix the +cause on the branch, and either move the tag (only safe while nothing has been +published for it) or cut a new version. + +## Required repository configuration + +The six qualification variables are read by the **verification** job, which +deliberately runs without an environment so it can run unattended. They must +therefore be available at **repository** scope: + +| Variable | Value | +|---|---| +| `SAFETY_QUALIFICATION_WHEEL_URL` | HTTPS URL for the exact qualified wheel | +| `SAFETY_QUALIFICATION_WHEEL_FILENAME` | Safe wheel basename | +| `SAFETY_QUALIFICATION_JSON_URL` | HTTPS URL for the qualified JSON artifact | +| `SAFETY_QUALIFICATION_SIGSTORE_BUNDLE_URL` | HTTPS URL for that artifact's Sigstore bundle | +| `SAFETY_QUALIFICATION_SIGNER_IDENTITY` | Trusted certificate identity for qualification promotion | +| `SAFETY_QUALIFICATION_OIDC_ISSUER` | Trusted OIDC issuer | + +These are variables, not secrets — they are URLs and identities, readable by any +workflow in the repository regardless of scope. Environment-scoped values still +override repository ones for the `publish` job. + +Moving them to repository scope does weaken *who can change them* relative to +environment-scoped variables. That is an accepted trade, because the control +that mattered is now stronger: a tampered wheel URL no longer reaches PyPI, it +fails the source-binding gate. Variable ACLs were doing work that +content-addressing now does directly. diff --git a/scripts/release_publication.py b/scripts/release_publication.py new file mode 100644 index 00000000..2fa0dc35 --- /dev/null +++ b/scripts/release_publication.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""Content-addressed handoff and idempotence guard for the publication job. + +Verification and publication run as separate jobs so that expensive, read-only +checking cannot hold write or OIDC permissions, and so an immutable PyPI upload +is never entangled with the steps that decide whether it should happen. That +split introduces a new obligation: the publication job must prove it is +shipping *exactly* the bytes verification approved, not merely an artifact with +the same name. + +``manifest`` records every candidate asset with its SHA-256. The verification +job emits the manifest's own digest as a job output; job outputs travel through +GitHub's trusted channel rather than the artifact store, so +``verify-manifest --expected-sha256`` closes the loop: + + job output digest -> manifest bytes -> per-asset digests -> asset bytes + +Any substitution in the artifact store breaks one of those links. + +``pypi-state`` answers the question a retry must ask before re-uploading an +immutable version. PyPI uploads cannot be replaced, so "just re-run the job" is +not a recovery procedure — it either fails confusingly or, worse, succeeds +against a version that already holds different bytes. The three states are: + +``absent`` + Version not on the index. Publication proceeds. +``published_identical`` + Version exists and the index holds this exact wheel digest. The upload + already succeeded; a re-run is completing an interrupted transaction, so + the publish step is skipped and finalization continues. +``published_divergent`` + Version exists with *different* bytes. Always fatal: the tag would ship + something other than what is on the index. + +Run from the repo root: + + python scripts/release_publication.py manifest --tag v0.16.0 \\ + --source-commit "$GITHUB_SHA" --wheel dist/agents_shipgate-0.16.0-py3-none-any.whl \\ + --asset dist/agents-shipgate-sbom.json --output dist/candidate-manifest.json + python scripts/release_publication.py verify-manifest \\ + --manifest dist/candidate-manifest.json --expected-sha256 "$DIGEST" + python scripts/release_publication.py pypi-state \\ + --wheel dist/agents_shipgate-0.16.0-py3-none-any.whl +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +from agents_shipgate.core.errors import ConfigError + +if __package__: + from scripts.run_safety_qualification import inspect_wheel, sha256_file +else: # ``python scripts/release_publication.py`` + from run_safety_qualification import inspect_wheel, sha256_file + +DEFAULT_INDEX = "https://pypi.org/pypi" +_NETWORK_TIMEOUT_SECONDS = 30 + + +def build_manifest( + *, + tag: str, + source_commit: str, + wheel_path: Path, + asset_paths: list[Path], + output_path: Path, +) -> dict[str, Any]: + """Write a content-addressed record of every asset the release will ship.""" + + wheel_name, wheel_version, wheel_sha256 = inspect_wheel(wheel_path) + if tag != f"v{wheel_version}": + raise ConfigError(f"Release tag {tag} does not match wheel version {wheel_version}") + + assets = [] + for path in sorted({*asset_paths, wheel_path}, key=lambda item: item.name): + if not path.is_file(): + raise ConfigError(f"Candidate asset not found: {path}") + assets.append({"filename": path.name, "sha256": sha256_file(path)}) + + manifest = { + "release_tag": tag, + "source_commit": source_commit, + "distribution": wheel_name, + "version": wheel_version, + "wheel_filename": wheel_path.name, + "wheel_sha256": wheel_sha256, + "assets": assets, + } + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return manifest + + +def verify_manifest( + *, manifest_path: Path, expected_sha256: str | None = None, directory: Path | None = None +) -> dict[str, Any]: + """Re-derive every digest the verification job recorded.""" + + if not manifest_path.is_file(): + raise ConfigError(f"Candidate manifest not found: {manifest_path}") + actual_sha256 = sha256_file(manifest_path) + if expected_sha256 and actual_sha256 != expected_sha256: + raise ConfigError( + "Candidate manifest digest does not match the verification job output " + f"(expected {expected_sha256}, got {actual_sha256}); the artifact handoff was " + "modified between verification and publication." + ) + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ConfigError(f"Invalid candidate manifest {manifest_path}: {exc}") from exc + + base = directory or manifest_path.parent + errors: list[str] = [] + for asset in manifest.get("assets", []): + path = base / str(asset.get("filename", "")) + if not path.is_file(): + errors.append(f"missing asset {asset.get('filename')}") + continue + digest = sha256_file(path) + if digest != asset.get("sha256"): + errors.append( + f"{asset.get('filename')} digest {digest} does not match " + f"the verified {asset.get('sha256')}" + ) + if errors: + raise ConfigError("Candidate handoff rejected: " + "; ".join(errors)) + return manifest + + +def _fetch_release_files(distribution: str, version: str, index: str) -> list[dict[str, Any]]: + url = f"{index.rstrip('/')}/{distribution}/{version}/json" + if not url.startswith("https://"): + raise ConfigError(f"Index URL must use HTTPS: {url}") + request = urllib.request.Request(url, headers={"Accept": "application/json"}) + try: + with urllib.request.urlopen( # noqa: S310 - scheme asserted https above + request, timeout=_NETWORK_TIMEOUT_SECONDS + ) as response: + payload = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return [] + raise ConfigError(f"Unable to query {url}: HTTP {exc.code}") from exc + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, UnicodeError) as exc: + # Deliberately not treated as "absent": an unreachable index must not + # be read as permission to upload. + raise ConfigError(f"Unable to query {url}: {exc}") from exc + return list(payload.get("urls", [])) + + +def pypi_state(*, wheel_path: Path, index: str = DEFAULT_INDEX) -> dict[str, Any]: + """Classify whether this exact wheel is already on the index.""" + + distribution, version, wheel_sha256 = inspect_wheel(wheel_path) + files = _fetch_release_files(distribution, version, index) + if not files: + state = "absent" + else: + digests = {str(item.get("digests", {}).get("sha256", "")) for item in files} + state = "published_identical" if wheel_sha256 in digests else "published_divergent" + + if state == "published_divergent": + raise ConfigError( + f"{distribution} {version} is already on the index with different bytes. " + "PyPI uploads are immutable, so this tag cannot be republished. Cut a new " + "version; see docs/release-runbook.md for the recovery procedure." + ) + return { + "state": state, + "distribution": distribution, + "version": version, + "wheel_sha256": wheel_sha256, + "should_publish": state == "absent", + } + + +def _emit_github_output(values: dict[str, Any], output_path: str | None) -> None: + if not output_path: + return + with Path(output_path).open("a", encoding="utf-8") as handle: + for key, value in values.items(): + handle.write(f"{key}={value}\n") + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Content-addressed release handoff and publication idempotence guard." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + manifest = subparsers.add_parser("manifest", help="record candidate assets and digests") + manifest.add_argument("--tag", required=True) + manifest.add_argument("--source-commit", required=True) + manifest.add_argument("--wheel", type=Path, required=True) + manifest.add_argument("--asset", type=Path, action="append", default=[]) + manifest.add_argument("--output", type=Path, required=True) + + verify = subparsers.add_parser("verify-manifest", help="re-derive the handoff digests") + verify.add_argument("--manifest", type=Path, required=True) + verify.add_argument("--expected-sha256") + verify.add_argument("--directory", type=Path) + + state = subparsers.add_parser("pypi-state", help="classify the index state for this wheel") + state.add_argument("--wheel", type=Path, required=True) + state.add_argument("--index", default=DEFAULT_INDEX) + state.add_argument("--github-output", help="append should_publish/state here") + + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + if args.command == "manifest": + manifest = build_manifest( + tag=args.tag, + source_commit=args.source_commit, + wheel_path=args.wheel, + asset_paths=list(args.asset), + output_path=args.output, + ) + digest = hashlib.sha256(args.output.read_bytes()).hexdigest() + sys.stdout.write( + f"OK: candidate manifest for {manifest['release_tag']} lists " + f"{len(manifest['assets'])} assets; manifest sha256 {digest}.\n" + ) + elif args.command == "verify-manifest": + manifest = verify_manifest( + manifest_path=args.manifest, + expected_sha256=args.expected_sha256, + directory=args.directory, + ) + sys.stdout.write( + f"OK: all {len(manifest['assets'])} candidate assets match the verified digests.\n" + ) + else: + result = pypi_state(wheel_path=args.wheel, index=args.index) + _emit_github_output( + { + "state": result["state"], + "should_publish": str(result["should_publish"]).lower(), + }, + args.github_output, + ) + sys.stdout.write( + f"OK: {result['distribution']} {result['version']} index state " + f"is {result['state']}; should_publish={result['should_publish']}.\n" + ) + except (ConfigError, OSError, ValueError) as exc: + sys.stderr.write(f"Release publication error: {exc}\n") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_sbom.py b/scripts/release_sbom.py new file mode 100644 index 00000000..c37522b6 --- /dev/null +++ b/scripts/release_sbom.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Generate and verify an SBOM scoped to the shipped wheel. + +The release workflow used to install ``.[dev]`` and run ``cyclonedx-py +environment`` against that, which inventoried the CI environment — pytest, +ruff, twine, Sigstore, and the CycloneDX tooling itself — rather than the +runtime dependency surface of the wheel being published. A signed SBOM that +describes the wrong artifact is worse than no SBOM: it is an attested claim +about software the user never receives. + +``build`` installs *only* the wheel into an isolated interpreter created with +``--without-pip``, so the resulting environment is exactly the wheel plus its +runtime closure, then inventories that. Two post-processing steps matter: + +1. CycloneDX records the wheel it installed from as an ``externalReferences`` + entry of type ``distribution``, including a ``file://`` URL pointing at the + build machine's temporary directory. That path varies per run, so it would + make the signed SBOM non-deterministic, and it leaks runner filesystem + layout into a published artifact. The URL is reduced to the wheel basename; + the SHA-256 alongside it is kept. +2. The inventory has no ``metadata.component``, so nothing in the document + says which artifact it describes. The wheel is promoted to that slot with + its digest, which is what ``verify`` later binds against. + +``verify`` re-derives the wheel digest and refuses any SBOM that describes +different bytes, a different version, or an environment containing a dev-only +distribution. It runs before publication so a mismatch cannot ship. + +Run from the repo root: + + python scripts/release_sbom.py build --wheel dist/agents_shipgate-*.whl \\ + --output dist/agents-shipgate-sbom.json + python scripts/release_sbom.py verify --wheel dist/agents_shipgate-*.whl \\ + --sbom dist/agents-shipgate-sbom.json +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +import tomllib +import venv +from pathlib import Path +from typing import Any + +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name + +from agents_shipgate.core.errors import ConfigError + +if __package__: + from scripts.run_safety_qualification import inspect_wheel +else: # ``python scripts/release_sbom.py`` + from run_safety_qualification import inspect_wheel + +REPO_ROOT = Path(__file__).resolve().parent.parent +WHEEL_FILENAME_PROPERTY = "agents-shipgate:wheel-filename" + + +def dev_only_distributions(pyproject_path: Path) -> set[str]: + """Return canonical names that must never appear in a runtime-only SBOM. + + Derived from the ``dev`` extra rather than hardcoded, so adding a new dev + tool extends the guard automatically. Declared runtime dependencies are + subtracted: a distribution that is legitimately needed at runtime is not + dev-only even if a dev tool also depends on it. + """ + + try: + data = tomllib.loads(pyproject_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, tomllib.TOMLDecodeError) as exc: + raise ConfigError(f"Unable to read {pyproject_path}: {exc}") from exc + project = data.get("project", {}) + dev = project.get("optional-dependencies", {}).get("dev", []) + runtime = project.get("dependencies", []) + if not dev: + raise ConfigError(f"{pyproject_path} declares no [project.optional-dependencies].dev") + dev_names = {canonicalize_name(Requirement(item).name) for item in dev} + runtime_names = {canonicalize_name(Requirement(item).name) for item in runtime} + return dev_names - runtime_names + + +def _component_names(document: dict[str, Any]) -> set[str]: + return { + canonicalize_name(str(component.get("name", ""))) + for component in document.get("components", []) + if component.get("name") + } + + +def _normalise_external_references(document: dict[str, Any], wheel_name: str) -> None: + """Replace build-machine ``file://`` URLs with the wheel basename.""" + + for component in [*document.get("components", []), document.get("metadata", {})]: + for reference in component.get("externalReferences", []) or []: + url = str(reference.get("url", "")) + if url.startswith("file://"): + reference["url"] = wheel_name + + +def _assert_runtime_only(document: dict[str, Any], *, sbom_path: Path) -> None: + forbidden = dev_only_distributions(REPO_ROOT / "pyproject.toml") + present = sorted(forbidden & _component_names(document)) + if present: + raise ConfigError( + f"SBOM {sbom_path} inventories dev-only distributions ({', '.join(present)}); " + "it describes a development environment rather than the shipped wheel." + ) + + +def build_release_sbom(*, wheel_path: Path, output_path: Path) -> dict[str, Any]: + """Inventory an isolated runtime-only installation of ``wheel_path``.""" + + wheel_name, wheel_version, wheel_sha256 = inspect_wheel(wheel_path) + + with tempfile.TemporaryDirectory(prefix="shipgate-sbom-") as workdir: + env_dir = Path(workdir) / "runtime" + # ``with_pip=False`` keeps pip, setuptools, and wheel out of the + # inventory: they are installer plumbing, not part of what ships. + # + # ``symlinks`` mirrors what ``python -m venv`` does on this platform. + # EnvBuilder's constructor defaults to False where the CLI defaults to + # True on POSIX, and a *copied* interpreter cannot resolve + # ``@rpath/libpython3.12.dylib`` on macOS, so the environment is built + # but every subsequent call into it dies in dyld. + venv.EnvBuilder(with_pip=False, clear=True, symlinks=os.name != "nt").create(env_dir) + env_python = env_dir / ("Scripts" if sys.platform == "win32" else "bin") / "python" + install = subprocess.run( + [ + sys.executable, + "-m", + "pip", + "--python", + str(env_python), + "install", + "--quiet", + "--no-input", + str(wheel_path), + ], + check=False, + capture_output=True, + text=True, + ) + if install.returncode != 0: + raise ConfigError( + f"Unable to install {wheel_path} into an isolated environment: {install.stderr}" + ) + + raw_path = Path(workdir) / "raw-sbom.json" + generate = subprocess.run( + [ + sys.executable, + "-m", + "cyclonedx_py", + "environment", + "--output-reproducible", + "--of", + "JSON", + "-o", + str(raw_path), + str(env_python), + ], + check=False, + capture_output=True, + text=True, + ) + if generate.returncode != 0: + raise ConfigError(f"cyclonedx-py failed for {wheel_path}: {generate.stderr}") + document = json.loads(raw_path.read_text(encoding="utf-8")) + + _normalise_external_references(document, wheel_path.name) + metadata = document.setdefault("metadata", {}) + metadata["component"] = { + "type": "library", + "bom-ref": f"{wheel_name}-wheel", + "name": wheel_name, + "version": wheel_version, + "hashes": [{"alg": "SHA-256", "content": wheel_sha256}], + "properties": [{"name": WHEEL_FILENAME_PROPERTY, "value": wheel_path.name}], + } + _assert_runtime_only(document, sbom_path=output_path) + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return document + + +def verify_release_sbom(*, wheel_path: Path, sbom_path: Path) -> dict[str, Any]: + """Fail closed unless the SBOM describes exactly this wheel's runtime surface.""" + + if not sbom_path.is_file(): + raise ConfigError(f"SBOM not found: {sbom_path}") + try: + document = json.loads(sbom_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ConfigError(f"Invalid SBOM {sbom_path}: {exc}") from exc + if not isinstance(document, dict): + raise ConfigError(f"SBOM {sbom_path} must contain a JSON object") + + wheel_name, wheel_version, wheel_sha256 = inspect_wheel(wheel_path) + component = document.get("metadata", {}).get("component") + if not isinstance(component, dict): + raise ConfigError(f"SBOM {sbom_path} has no metadata.component to bind against") + + errors: list[str] = [] + if canonicalize_name(str(component.get("name", ""))) != wheel_name: + errors.append(f"SBOM component name {component.get('name')!r} is not {wheel_name!r}") + if str(component.get("version", "")) != wheel_version: + errors.append(f"SBOM component version {component.get('version')!r} is not {wheel_version}") + digests = { + str(entry.get("content", "")) + for entry in component.get("hashes", []) or [] + if str(entry.get("alg", "")).upper() == "SHA-256" + } + if wheel_sha256 not in digests: + errors.append( + f"SBOM records no SHA-256 matching the wheel ({wheel_sha256}); found {sorted(digests)}" + ) + if errors: + raise ConfigError( + f"SBOM {sbom_path} is not bound to {wheel_path.name}: " + "; ".join(errors) + ) + + _assert_runtime_only(document, sbom_path=sbom_path) + return document + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Build or verify an SBOM scoped to the published wheel." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + build = subparsers.add_parser("build", help="generate a wheel-scoped SBOM") + build.add_argument("--wheel", type=Path, required=True) + build.add_argument("--output", type=Path, required=True) + + verify = subparsers.add_parser("verify", help="verify an SBOM is bound to the wheel") + verify.add_argument("--wheel", type=Path, required=True) + verify.add_argument("--sbom", type=Path, required=True) + + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + if args.command == "build": + build_release_sbom(wheel_path=args.wheel, output_path=args.output) + target = args.output + else: + verify_release_sbom(wheel_path=args.wheel, sbom_path=args.sbom) + target = args.sbom + except (ConfigError, OSError, ValueError) as exc: + sys.stderr.write(f"Release SBOM error: {exc}\n") + return 1 + sys.stdout.write(f"OK: {target} describes the runtime surface of {args.wheel.name}.\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_wheel_provenance.py b/scripts/verify_wheel_provenance.py new file mode 100644 index 00000000..f0b324ce --- /dev/null +++ b/scripts/verify_wheel_provenance.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Bind the qualified/published wheel to a wheel built from the tagged source. + +The release pipeline previously established three bindings — tag to +``pyproject.toml`` version, qualification payload to wheel bytes, and tag to +the wheel's own ``METADATA`` version — but nothing tied the shipped bytes back +to the tagged commit. Any wheel declaring ``Name: agents-shipgate`` and the +right ``Version`` satisfied every check regardless of what source produced it. + +This module closes that gap. The release job builds a wheel from the tagged +checkout and this verifier requires it to match the qualified wheel: + +``identical_bytes`` + SHA-256 of both archives is equal. This is the preferred outcome and the + only one accepted by default: it makes every release a reproducible-build + check as a side effect. + +``identical_payload`` + Archive bytes differ, but every member name, permission mode, and member + content digest is equal — the difference is zip container metadata only + (entry ordering, timestamps, compression level). This is the explicitly + approved *interim* control for the case where wheel archives are not yet + bit-for-bit reproducible. It is rejected unless + ``--allow-payload-equivalent`` is passed, so the weaker bar can never be + taken silently. + +``mismatch`` + Member sets or member contents differ. Always fatal. + +A ``Generator:`` change in ``.dist-info/WHEEL`` (a build-backend version bump) +is a *content* difference and therefore a mismatch, not a tolerated container +difference. That is deliberate: the fix is to align the pinned build backend +(``constraints/release-build.txt``), not to widen the comparison. The report +names the differing member so the operator can see that immediately. + +Run from the repo root: + + python scripts/verify_wheel_provenance.py \\ + --built dist-build/agents_shipgate-0.16.0-py3-none-any.whl \\ + --qualified qualified-dist/agents_shipgate-0.16.0-py3-none-any.whl \\ + --report provenance.json +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +import zipfile +from pathlib import Path +from typing import Literal + +from agents_shipgate.core.errors import ConfigError + +ProvenanceMode = Literal["identical_bytes", "identical_payload", "mismatch"] + +# Number of differing members named in the failure message. A full listing of a +# 500-member wheel buries the signal in CI logs; the first few are enough to +# tell "backend version bump" from "different source tree" apart. +_MAX_REPORTED_DIFFERENCES = 20 + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _wheel_payload(path: Path) -> dict[str, tuple[str, str]]: + """Return ``member name -> (content sha256, permission mode)``. + + Directory entries are skipped: they carry no payload and their presence + varies between zip writers. Permission bits are normalised to the low 12 + bits because some writers store the file-type bits in ``external_attr`` + and some do not, which is a container detail rather than a payload one. + """ + + if not path.is_file() or path.suffix != ".whl": + raise ConfigError(f"Wheel not found or not a .whl file: {path}") + payload: dict[str, tuple[str, str]] = {} + try: + with zipfile.ZipFile(path) as archive: + for info in archive.infolist(): + if info.is_dir(): + continue + mode = oct((info.external_attr >> 16) & 0o7777) + payload[info.filename] = ( + hashlib.sha256(archive.read(info.filename)).hexdigest(), + mode, + ) + except (OSError, zipfile.BadZipFile, KeyError) as exc: + raise ConfigError(f"Invalid wheel {path}: {exc}") from exc + if not payload: + raise ConfigError(f"Wheel contains no files: {path}") + return payload + + +def _describe_differences( + built: dict[str, tuple[str, str]], + qualified: dict[str, tuple[str, str]], +) -> list[str]: + differences: list[str] = [] + for name in sorted(set(built) - set(qualified)): + differences.append(f"only in built wheel: {name}") + for name in sorted(set(qualified) - set(built)): + differences.append(f"only in qualified wheel: {name}") + for name in sorted(set(built) & set(qualified)): + built_digest, built_mode = built[name] + qualified_digest, qualified_mode = qualified[name] + if built_digest != qualified_digest: + differences.append( + f"content differs: {name} " + f"(built {built_digest[:12]}, qualified {qualified_digest[:12]})" + ) + elif built_mode != qualified_mode: + differences.append( + f"mode differs: {name} (built {built_mode}, qualified {qualified_mode})" + ) + return differences + + +def compare_wheels(built_path: Path, qualified_path: Path) -> tuple[ProvenanceMode, list[str]]: + """Classify how a wheel built from source relates to the qualified wheel.""" + + built_sha256 = _sha256_file(built_path) + qualified_sha256 = _sha256_file(qualified_path) + if built_sha256 == qualified_sha256: + return "identical_bytes", [] + + differences = _describe_differences(_wheel_payload(built_path), _wheel_payload(qualified_path)) + if not differences: + return "identical_payload", [] + return "mismatch", differences + + +def verify_wheel_provenance( + *, + built_path: Path, + qualified_path: Path, + allow_payload_equivalent: bool = False, + source_commit: str | None = None, +) -> dict[str, object]: + """Return a provenance record, or raise ``ConfigError`` if unpublishable. + + Raising is the whole point: the caller runs before ``uv publish``, so an + exception here is what keeps an unbound artifact off PyPI. + """ + + mode, differences = compare_wheels(built_path, qualified_path) + if mode == "mismatch": + shown = differences[:_MAX_REPORTED_DIFFERENCES] + omitted = len(differences) - len(shown) + detail = "; ".join(shown) + if omitted > 0: + detail += f"; (+{omitted} more)" + raise ConfigError( + "Wheel built from the tagged source does not match the qualified wheel. " + "The qualified wheel was not produced by this source tree, or the build " + "backend pin drifted. Differences: " + detail + ) + if mode == "identical_payload" and not allow_payload_equivalent: + raise ConfigError( + "Wheel archives are not byte-identical. Member contents match, so this is a " + "build-reproducibility gap rather than a source mismatch, but the stronger bar " + "is required by default. Align the build backend pin in " + "constraints/release-build.txt, or re-run with --allow-payload-equivalent " + "as an explicit, tracked interim control." + ) + + return { + "provenance_mode": mode, + "built_wheel": built_path.name, + "built_wheel_sha256": _sha256_file(built_path), + "qualified_wheel": qualified_path.name, + "qualified_wheel_sha256": _sha256_file(qualified_path), + "source_commit": source_commit, + "byte_reproducible": mode == "identical_bytes", + } + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Verify the qualified wheel was produced by the tagged source tree." + ) + parser.add_argument("--built", type=Path, required=True, help="wheel built from the checkout") + parser.add_argument( + "--qualified", type=Path, required=True, help="signed, qualified wheel to be published" + ) + parser.add_argument("--report", type=Path, help="write a JSON provenance record here") + parser.add_argument("--source-commit", help="commit SHA the built wheel came from") + parser.add_argument( + "--allow-payload-equivalent", + action="store_true", + help=( + "accept container-metadata-only differences as an explicit interim control; " + "the remaining reproducibility gap must be tracked separately" + ), + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + record = verify_wheel_provenance( + built_path=args.built, + qualified_path=args.qualified, + allow_payload_equivalent=args.allow_payload_equivalent, + source_commit=args.source_commit, + ) + if args.report: + args.report.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", "utf-8") + except (ConfigError, OSError, ValueError) as exc: + sys.stderr.write(f"Wheel provenance error: {exc}\n") + return 1 + sys.stdout.write( + f"OK: qualified wheel is bound to the tagged source ({record['provenance_mode']}); " + f"sha256 {record['qualified_wheel_sha256']}.\n" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_action_metadata.py b/tests/test_action_metadata.py index b01bb711..fad15775 100644 --- a/tests/test_action_metadata.py +++ b/tests/test_action_metadata.py @@ -324,8 +324,26 @@ def test_marketplace_action_repo_has_ci_and_release_workflows(): def test_release_workflow_uses_release_security_steps(): + import yaml + text = Path(".github/workflows/release.yml").read_text(encoding="utf-8") assert "uv publish --trusted-publishing always" in text assert "sigstore sign" in text - assert "cyclonedx-py environment" in text + + # Checked against the commands the workflows actually run, not their text: + # both files discuss the superseded `cyclonedx-py environment` scan in + # comments explaining why it was replaced. + commands = [] + for name in ("release.yml", "release-verify.yml"): + parsed = yaml.safe_load(Path(".github/workflows", name).read_text(encoding="utf-8")) + for job in parsed["jobs"].values(): + commands.extend(step["run"] for step in (job.get("steps") or []) if "run" in step) + joined = "\n".join(commands) + + # The SBOM describes an isolated runtime-only install of the shipped + # wheel. Scanning the CI environment inventoried pytest, ruff, twine and + # Sigstore instead — a signed attestation about software the user never + # receives. + assert "scripts/release_sbom.py build" in joined + assert "cyclonedx-py environment" not in joined diff --git a/tests/test_release_pipeline.py b/tests/test_release_pipeline.py new file mode 100644 index 00000000..6155787b --- /dev/null +++ b/tests/test_release_pipeline.py @@ -0,0 +1,534 @@ +"""Release-integrity contracts for the tag-triggered publication pipeline. + +These tests guard properties that are otherwise invisible until a `v*` tag is +pushed — the one moment when getting them wrong is irreversible, because PyPI +uploads cannot be replaced. They cover the five controls that make a release +verifiable rather than merely automated: + +* the published wheel is the one the tagged source produces (#342); +* the signed SBOM describes that wheel, not the CI environment (#356); +* verification and publication are separate, and a partial publish is + recoverable (#343); +* the correctness suite is deterministic and matches CI's selection (#344); +* the whole path is rehearsable without any publication authority (#355). +""" + +from __future__ import annotations + +import json +import zipfile +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from agents_shipgate.core.errors import ConfigError +from scripts.release_publication import pypi_state, verify_manifest +from scripts.release_sbom import dev_only_distributions, verify_release_sbom +from scripts.verify_wheel_provenance import compare_wheels, verify_wheel_provenance + +REPO_ROOT = Path(__file__).resolve().parent.parent +WORKFLOWS = REPO_ROOT / ".github/workflows" + +WHEEL_METADATA = "Metadata-Version: 2.4\nName: agents-shipgate\nVersion: 9.9.9\n" +DIST_INFO = "agents_shipgate-9.9.9.dist-info" + + +def _write_wheel(path: Path, members: dict[str, str] | None = None, **kwargs: Any) -> Path: + """Write a minimal but structurally valid wheel.""" + + payload = {f"{DIST_INFO}/METADATA": WHEEL_METADATA, "agents_shipgate/__init__.py": "x = 1\n"} + payload.update(members or {}) + with zipfile.ZipFile(path, "w", **kwargs) as archive: + for name, content in payload.items(): + archive.writestr(name, content) + return path + + +def _load_workflow(name: str) -> dict[str, Any]: + document = yaml.safe_load((WORKFLOWS / name).read_text(encoding="utf-8")) + # PyYAML resolves the bare key `on` to the boolean True (YAML 1.1), so + # triggers are normalised here rather than at every call site. + if True in document: + document["on"] = document.pop(True) + return document + + +def _step_index(job: dict[str, Any], needle: str) -> int: + for index, step in enumerate(job["steps"]): + if needle in json.dumps(step): + return index + raise AssertionError(f"no step matching {needle!r}") + + +# -------------------------------------------------------------------------- +# #342 — the published wheel comes from the tagged source +# -------------------------------------------------------------------------- + + +def test_identical_wheels_are_bound_by_bytes(tmp_path: Path) -> None: + built = _write_wheel(tmp_path / "built.whl") + qualified = _write_wheel(tmp_path / "qualified.whl") + + record = verify_wheel_provenance(built_path=built, qualified_path=qualified) + + assert record["provenance_mode"] == "identical_bytes" + assert record["byte_reproducible"] is True + + +def test_qualified_wheel_carrying_extra_code_fails_closed(tmp_path: Path) -> None: + """The attack #342 describes: a wheel with the right Name and Version but + contents no tagged commit produced.""" + + built = _write_wheel(tmp_path / "built.whl") + qualified = _write_wheel( + tmp_path / "qualified.whl", {"agents_shipgate/_backdoor.py": "import os\n"} + ) + + with pytest.raises(ConfigError) as excinfo: + verify_wheel_provenance(built_path=built, qualified_path=qualified) + + assert "_backdoor.py" in str(excinfo.value) + # Even the explicitly weaker interim bar must not let this through. + with pytest.raises(ConfigError): + verify_wheel_provenance( + built_path=built, qualified_path=qualified, allow_payload_equivalent=True + ) + + +def test_modified_module_contents_fail_closed(tmp_path: Path) -> None: + built = _write_wheel(tmp_path / "built.whl") + qualified = _write_wheel( + tmp_path / "qualified.whl", {"agents_shipgate/__init__.py": "x = 666\n"} + ) + + with pytest.raises(ConfigError) as excinfo: + verify_wheel_provenance(built_path=built, qualified_path=qualified) + + assert "content differs" in str(excinfo.value) + + +def test_container_only_difference_is_rejected_unless_explicitly_allowed(tmp_path: Path) -> None: + """A repacked archive with identical payload is a reproducibility gap, not + a source mismatch — but the weaker bar must be opted into, never inferred.""" + + built = _write_wheel(tmp_path / "built.whl", compression=zipfile.ZIP_STORED) + qualified = _write_wheel(tmp_path / "qualified.whl", compression=zipfile.ZIP_DEFLATED) + + mode, differences = compare_wheels(built, qualified) + assert mode == "identical_payload" + assert differences == [] + + with pytest.raises(ConfigError, match="not byte-identical"): + verify_wheel_provenance(built_path=built, qualified_path=qualified) + + record = verify_wheel_provenance( + built_path=built, qualified_path=qualified, allow_payload_equivalent=True + ) + assert record["provenance_mode"] == "identical_payload" + assert record["byte_reproducible"] is False + + +def test_release_verification_gates_publication_on_source_binding() -> None: + verify = _load_workflow("release-verify.yml")["jobs"]["verify"] + + build_index = _step_index(verify, "python -m build --wheel") + provenance_index = _step_index(verify, "scripts/verify_wheel_provenance.py") + handoff_index = _step_index(verify, "scripts/release_publication.py manifest") + + assert build_index < provenance_index < handoff_index + + +def test_build_backend_is_pinned_so_byte_equality_is_achievable() -> None: + """Wheels record `Generator: hatchling `, so an unpinned backend + makes the byte-equality gate fail on legitimate releases.""" + + constraints = (REPO_ROOT / "constraints/release-build.txt").read_text(encoding="utf-8") + assert "hatchling==" in constraints + + verify = _load_workflow("release-verify.yml")["jobs"]["verify"] + build_step = verify["steps"][_step_index(verify, "python -m build --wheel")] + assert build_step["env"]["PIP_CONSTRAINT"] == "constraints/release-build.txt" + + +# -------------------------------------------------------------------------- +# #356 — the SBOM describes the shipped wheel +# -------------------------------------------------------------------------- + + +def _sbom(components: list[str], *, digest: str, version: str = "9.9.9") -> dict[str, Any]: + return { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "metadata": { + "component": { + "type": "library", + "name": "agents-shipgate", + "version": version, + "hashes": [{"alg": "SHA-256", "content": digest}], + } + }, + "components": [{"name": name, "version": "1.0"} for name in components], + } + + +def _digest(path: Path) -> str: + import hashlib + + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_dev_only_guard_covers_the_release_tooling_that_leaked_before() -> None: + forbidden = dev_only_distributions(REPO_ROOT / "pyproject.toml") + + # Exactly the packages `cyclonedx-py environment` over `.[dev]` inventoried. + assert {"pytest", "ruff", "twine", "sigstore", "cyclonedx-bom", "pip-audit"} <= forbidden + # Runtime dependencies must never be classified as dev-only. + assert not forbidden & {"pydantic", "typer", "pyyaml", "cryptography", "packaging"} + + +def test_sbom_containing_a_dev_only_dependency_is_rejected(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") + sbom_path = tmp_path / "sbom.json" + sbom_path.write_text( + json.dumps(_sbom(["pydantic", "pytest"], digest=_digest(wheel))), encoding="utf-8" + ) + + with pytest.raises(ConfigError, match="dev-only"): + verify_release_sbom(wheel_path=wheel, sbom_path=sbom_path) + + +def test_sbom_for_a_runtime_only_environment_is_accepted(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") + sbom_path = tmp_path / "sbom.json" + sbom_path.write_text( + json.dumps(_sbom(["agents-shipgate", "pydantic", "typer"], digest=_digest(wheel))), + encoding="utf-8", + ) + + assert verify_release_sbom(wheel_path=wheel, sbom_path=sbom_path) + + +def test_sbom_describing_different_bytes_fails_before_publication(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") + other = _write_wheel(tmp_path / "other.whl", {"agents_shipgate/_extra.py": "y = 2\n"}) + sbom_path = tmp_path / "sbom.json" + sbom_path.write_text(json.dumps(_sbom(["pydantic"], digest=_digest(other))), encoding="utf-8") + + with pytest.raises(ConfigError, match="no SHA-256 matching the wheel"): + verify_release_sbom(wheel_path=wheel, sbom_path=sbom_path) + + +def test_sbom_without_a_bound_component_is_rejected(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") + sbom_path = tmp_path / "sbom.json" + sbom_path.write_text(json.dumps({"bomFormat": "CycloneDX", "components": []}), encoding="utf-8") + + with pytest.raises(ConfigError, match="no metadata.component"): + verify_release_sbom(wheel_path=wheel, sbom_path=sbom_path) + + +def test_publication_rechecks_the_sbom_binding_before_uploading() -> None: + publish = _load_workflow("release.yml")["jobs"]["publish"] + + assert _step_index(publish, "scripts/release_sbom.py verify") < _step_index( + publish, "uv publish" + ) + + +# -------------------------------------------------------------------------- +# #343 — separated verification, recoverable publication +# -------------------------------------------------------------------------- + + +def test_verification_and_publication_are_separate_jobs() -> None: + release = _load_workflow("release.yml") + + assert set(release["jobs"]) == {"verify", "publish"} + assert release["jobs"]["verify"]["uses"] == "./.github/workflows/release-verify.yml" + assert release["jobs"]["publish"]["needs"] == "verify" + + +def test_permissions_are_least_privilege() -> None: + release = _load_workflow("release.yml") + verify_workflow = _load_workflow("release-verify.yml") + + # No ambient authority at workflow level. + assert release["permissions"] == {} + # Verification is read-only, in both the caller and the called workflow. + assert release["jobs"]["verify"]["permissions"] == {"contents": "read"} + assert verify_workflow["permissions"] == {"contents": "read"} + assert verify_workflow["jobs"]["verify"]["permissions"] == {"contents": "read"} + assert "id-token" not in verify_workflow["jobs"]["verify"]["permissions"] + # Write and OIDC are scoped to publication alone. + assert release["jobs"]["publish"]["permissions"] == { + "contents": "write", + "id-token": "write", + } + assert release["jobs"]["publish"]["environment"] == "pypi" + + +def test_release_concurrency_is_serialised_across_the_pypi_project() -> None: + concurrency = _load_workflow("release.yml")["concurrency"] + + # A per-tag group would let two tags race to publish the same distribution. + assert "${{" not in concurrency["group"] + assert concurrency["cancel-in-progress"] is False + + +def test_draft_release_exists_before_publication_and_is_finalised_after() -> None: + publish = _load_workflow("release.yml")["jobs"]["publish"] + + draft_index = _step_index(publish, "--draft \\") + publish_index = _step_index(publish, "uv publish") + validate_index = _step_index(publish, "is missing required asset") + finalise_index = _step_index(publish, "--draft=false") + + # A failure after the immutable upload must leave a discoverable draft + # holding the authoritative assets. + assert draft_index < publish_index < validate_index < finalise_index + + +def test_publication_is_idempotence_aware() -> None: + publish = _load_workflow("release.yml")["jobs"]["publish"] + upload = publish["steps"][_step_index(publish, "uv publish")] + + assert upload["if"] == "steps.index.outputs.should_publish == 'true'" + + +def test_pre_release_tags_are_not_promoted_to_latest() -> None: + """The project tags betas (`v0.16.0b7`). Finalising those with `--latest` + would advertise a pre-release as the current version.""" + + publish = _load_workflow("release.yml")["jobs"]["publish"] + draft = publish["steps"][_step_index(publish, "--draft \\")]["run"] + + assert "--prerelease" in draft + assert "^v[0-9]+\\.[0-9]+\\.[0-9]+$" in draft + # Finalisation reuses the maturity decided at draft time rather than + # hardcoding one. + finalise = publish["steps"][_step_index(publish, "--draft=false")]["run"] + assert "RELEASE_MATURITY" in finalise + + +def test_handoff_rejects_an_asset_swapped_after_verification(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") + manifest = tmp_path / "candidate-manifest.json" + manifest.write_text( + json.dumps( + { + "release_tag": "v9.9.9", + "assets": [{"filename": wheel.name, "sha256": _digest(wheel)}], + } + ), + encoding="utf-8", + ) + expected = _digest(manifest) + + assert verify_manifest(manifest_path=manifest, expected_sha256=expected) + + _write_wheel(wheel, {"agents_shipgate/_swapped.py": "z = 3\n"}) + with pytest.raises(ConfigError, match="does not match the verified"): + verify_manifest(manifest_path=manifest, expected_sha256=expected) + + +def test_handoff_rejects_a_manifest_rewritten_to_match_a_swap(tmp_path: Path) -> None: + """The manifest digest travels through the job-output channel, so + rewriting the manifest inside the artifact store does not help.""" + + wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") + manifest = tmp_path / "candidate-manifest.json" + manifest.write_text( + json.dumps({"assets": [{"filename": wheel.name, "sha256": _digest(wheel)}]}), + encoding="utf-8", + ) + verified_digest = _digest(manifest) + + _write_wheel(wheel, {"agents_shipgate/_swapped.py": "z = 3\n"}) + manifest.write_text( + json.dumps({"assets": [{"filename": wheel.name, "sha256": _digest(wheel)}]}), + encoding="utf-8", + ) + + with pytest.raises(ConfigError, match="artifact handoff was modified"): + verify_manifest(manifest_path=manifest, expected_sha256=verified_digest) + + +@pytest.mark.parametrize( + ("files", "expected_state", "should_publish"), + [ + ([], "absent", True), + ([{"digests": {"sha256": "MATCH"}}], "published_identical", False), + ], +) +def test_pypi_state_classification( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + files: list[dict[str, Any]], + expected_state: str, + should_publish: bool, +) -> None: + wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") + digest = _digest(wheel) + resolved = [ + {"digests": {"sha256": digest if item["digests"]["sha256"] == "MATCH" else "other"}} + for item in files + ] + monkeypatch.setattr( + "scripts.release_publication._fetch_release_files", lambda *a, **k: resolved + ) + + result = pypi_state(wheel_path=wheel) + + assert result["state"] == expected_state + assert result["should_publish"] is should_publish + + +def test_republishing_a_version_with_different_bytes_is_fatal( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") + monkeypatch.setattr( + "scripts.release_publication._fetch_release_files", + lambda *a, **k: [{"digests": {"sha256": "0" * 64}}], + ) + + with pytest.raises(ConfigError, match="already on the index with different bytes"): + pypi_state(wheel_path=wheel) + + +def test_an_unreachable_index_is_not_read_as_permission_to_upload( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") + + def _explode(*_args: Any, **_kwargs: Any) -> list[dict[str, Any]]: + raise ConfigError("Unable to query https://pypi.org/pypi: timed out") + + monkeypatch.setattr("scripts.release_publication._fetch_release_files", _explode) + + with pytest.raises(ConfigError, match="Unable to query"): + pypi_state(wheel_path=wheel) + + +# -------------------------------------------------------------------------- +# #344 — deterministic correctness evidence +# -------------------------------------------------------------------------- + + +def _test_step_command(job: dict[str, Any], name: str) -> str: + for step in job["steps"]: + if step.get("name") == name: + return str(step["run"]) + raise AssertionError(f"no step named {name!r}") + + +def test_release_matches_ci_parallelism_and_excludes_perf() -> None: + release_test = _test_step_command( + _load_workflow("release-verify.yml")["jobs"]["verify"], "Test" + ) + ci_test = _test_step_command(_load_workflow("ci.yml")["jobs"]["test"], "Test") + + # Same supported parallelism as CI: a release candidate should not spend + # its budget re-running serially what CI already parallelises. + assert "-n auto" in release_test + assert "-n auto" in ci_test + # Latency budgets stay a merge-time gate; shared-runner timing noise must + # not be able to fail a release candidate. + assert '-m "not perf"' in release_test + assert "tests/test_latency_budget.py" not in release_test + + +def test_release_does_not_weaken_the_coverage_floor() -> None: + release_test = _test_step_command( + _load_workflow("release-verify.yml")["jobs"]["verify"], "Test" + ) + ci_test = _test_step_command(_load_workflow("ci.yml")["jobs"]["test"], "Test") + + assert "--cov-fail-under=85" in release_test + assert "--cov-fail-under=85" in ci_test + + +def test_adapter_static_only_lint_stays_covered_in_release() -> None: + """It is excluded from the aggregate run, so it needs its own step or the + trust-model invariant silently stops being checked at release time.""" + + verify = _load_workflow("release-verify.yml")["jobs"]["verify"] + aggregate = _test_step_command(verify, "Test") + + assert "--ignore=tests/test_adapter_static_only.py" in aggregate + assert _step_index(verify, "tests/test_adapter_static_only.py -q") < _step_index( + verify, "--cov-fail-under=85" + ) + + +def test_release_verification_timeout_is_documented_and_bounded() -> None: + verify = _load_workflow("release-verify.yml")["jobs"]["verify"] + source = (WORKFLOWS / "release-verify.yml").read_text(encoding="utf-8") + + assert verify["timeout-minutes"] == 25 + # The number has to be traceable to a measurement, not an estimate. + assert "Measured, not estimated" in source + + +def test_perf_marker_is_declared_so_the_exclusion_is_meaningful() -> None: + pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + + assert "perf: latency-budget" in pyproject + + +# -------------------------------------------------------------------------- +# #355 — the rehearsal cannot publish +# -------------------------------------------------------------------------- + + +def test_rehearsal_exercises_the_same_verification_workflow() -> None: + rehearsal = _load_workflow("release-rehearsal.yml") + release = _load_workflow("release.yml") + + shared = "./.github/workflows/release-verify.yml" + assert rehearsal["jobs"]["rehearse"]["uses"] == shared + assert release["jobs"]["verify"]["uses"] == shared + assert rehearsal["jobs"]["rehearse"]["with"]["mode"] == "rehearsal" + + +def test_rehearsal_has_no_publication_job_to_instantiate() -> None: + rehearsal = _load_workflow("release-rehearsal.yml") + + # Asserted against the parsed workflow rather than its text: the file + # explains in prose which publication verbs it deliberately omits, and a + # substring check would match that explanation. + assert set(rehearsal["jobs"]) == {"rehearse"} + for job in rehearsal["jobs"].values(): + # A pure reusable-workflow call. There is no step list to smuggle a + # publish command into. + assert "steps" not in job + assert "environment" not in job + assert "run" not in job + + +def test_rehearsal_holds_no_write_or_oidc_authority() -> None: + rehearsal = _load_workflow("release-rehearsal.yml") + + assert rehearsal["permissions"] == {"contents": "read"} + assert rehearsal["jobs"]["rehearse"]["permissions"] == {"contents": "read"} + # A reusable workflow cannot be granted more than it declares, so this cap + # holds even if a caller asks for more. + assert _load_workflow("release-verify.yml")["permissions"] == {"contents": "read"} + + +def test_rehearsal_is_manually_dispatchable_and_not_tag_triggered() -> None: + triggers = _load_workflow("release-rehearsal.yml")["on"] + + assert set(triggers) == {"workflow_dispatch"} + + +def test_rehearsal_publishes_inspectable_candidate_artifacts() -> None: + verify = _load_workflow("release-verify.yml")["jobs"]["verify"] + + upload = verify["steps"][_step_index(verify, "actions/upload-artifact")] + assert upload["with"]["if-no-files-found"] == "error" + assert _step_index(verify, "GITHUB_STEP_SUMMARY") > _step_index( + verify, "actions/upload-artifact" + ) diff --git a/tests/test_safety_qualification_release.py b/tests/test_safety_qualification_release.py index 77b48314..092f4397 100644 --- a/tests/test_safety_qualification_release.py +++ b/tests/test_safety_qualification_release.py @@ -300,31 +300,53 @@ def test_release_validator_cli_fails_closed(tmp_path: Path) -> None: def test_release_workflow_reuses_signed_qualified_wheel_before_publish() -> None: - workflow_path = REPO_ROOT / ".github/workflows/release.yml" - workflow = workflow_path.read_text(encoding="utf-8") - - signature_index = workflow.index("sigstore verify identity") - binding_index = workflow.index("scripts/verify_safety_qualification_release.py") - publish_index = workflow.index("uv publish --trusted-publishing always") - assert signature_index < binding_index < publish_index - assert "SAFETY_QUALIFICATION_WHEEL_URL" in workflow - assert "SAFETY_QUALIFICATION_JSON_URL" in workflow - assert "SAFETY_QUALIFICATION_SIGSTORE_BUNDLE_URL" in workflow - assert 'uv publish --trusted-publishing always "dist/${QUALIFIED_WHEEL_FILENAME}"' in workflow - assert "python -m build" not in workflow - assert "dist/*.tar.gz" not in workflow - assert "dist/safety-qualification.json" in workflow - assert "dist/safety-qualification.sigstore.json" in workflow - - parsed = yaml.safe_load(workflow) - for step in parsed["jobs"]["release"]["steps"]: - if "run" in step: - subprocess.run( - ["bash", "-n", "-c", step["run"]], - check=True, - capture_output=True, - text=True, - ) + """The published wheel stays the *qualified* one, and every binding + precedes publication. + + Verification now builds a wheel from the tagged source too, but only to + compare against the qualified wheel — the artifact that reaches PyPI is + still the signed, qualified one, never a freshly built substitute. + """ + + verify = (REPO_ROOT / ".github/workflows/release-verify.yml").read_text(encoding="utf-8") + release = (REPO_ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + + signature_index = verify.index("sigstore verify identity") + binding_index = verify.index("scripts/verify_safety_qualification_release.py") + provenance_index = verify.index("scripts/verify_wheel_provenance.py") + assert signature_index < binding_index < provenance_index + assert "SAFETY_QUALIFICATION_WHEEL_URL" in verify + assert "SAFETY_QUALIFICATION_JSON_URL" in verify + assert "SAFETY_QUALIFICATION_SIGSTORE_BUNDLE_URL" in verify + + # Publication lives in a different job that cannot start until the + # verification job succeeds, so ordering is enforced by the dependency + # graph rather than by step position. + assert "uv publish --trusted-publishing always" not in verify + parsed_release = yaml.safe_load(release) + assert parsed_release["jobs"]["publish"]["needs"] == "verify" + + # The wheel is addressed by the filename verification approved, and the + # source-built wheel never enters the publishable set. + assert 'uv publish --trusted-publishing always "dist/${WHEEL_FILENAME}"' in release + assert "source-build" not in release + assert "dist/*.tar.gz" not in release + assert "safety-qualification.json" in verify + assert "safety-qualification.sigstore.json" in verify + + for workflow in ("release.yml", "release-verify.yml", "release-rehearsal.yml"): + parsed = yaml.safe_load( + (REPO_ROOT / ".github/workflows" / workflow).read_text(encoding="utf-8") + ) + for job in parsed["jobs"].values(): + for step in job.get("steps") or []: + if "run" in step: + subprocess.run( + ["bash", "-n", "-c", step["run"]], + check=True, + capture_output=True, + text=True, + ) def test_release_validator_is_directly_executable_from_the_documented_command() -> None: From 3e40d3aa55010bedd638ea8aeb6c8f77e1650d50 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Sun, 9 Aug 2026 14:16:50 -0700 Subject: [PATCH 2/8] docs(release): ground the verification timeout in observed runner timings Replaces the local CPU-time extrapolation with per-phase durations measured on a hosted runner (CI run 31336011667): the correctness suite is 407s and the supporting steps ~40s, so a healthy verification run lands near 10 minutes against the 25-minute budget. Co-Authored-By: Claude Opus 5 --- .github/workflows/release-verify.yml | 15 ++++++++++----- docs/release-runbook.md | 22 ++++++++++++++++------ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index 0be2de02..9ccba595 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -58,11 +58,16 @@ permissions: jobs: verify: runs-on: ubuntu-latest - # Measured, not estimated: the correctness suite runs ~7 min wall clock at - # `-n auto` on a 2-core hosted runner, and build + qualification + audit + - # SBOM add ~4 min. 25 minutes leaves roughly 2x headroom on the dominant - # term. Re-derive this from the rehearsal timings printed in the readiness - # summary whenever the suite grows materially; see docs/release-runbook.md. + # Measured, not estimated. Observed on a hosted runner (CI run 31336011667, + # the same suite selection this job uses): the correctness suite is 407s, + # and install + lint + compile + schema check + static lint + audit total + # ~40s. This job adds a source build, artifact download, signature and + # qualification verification, and the isolated SBOM install — together + # ~2 min, dominated by the SBOM environment's dependency install. + # + # That puts a healthy run near 10 minutes; 25 leaves roughly 2.5x headroom. + # Re-derive from the rehearsal timings whenever the suite grows materially, + # and see docs/release-runbook.md before raising it after a single timeout. timeout-minutes: 25 permissions: contents: read diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 164a3338..13711c56 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -93,14 +93,24 @@ variable afterwards. ### Re-deriving the timeout -`release-verify.yml` sets `timeout-minutes: 25`. The dominant term is the -correctness suite (~710s of CPU work; roughly 6 minutes wall clock at `-n auto` -on a 2-core hosted runner), plus ~4 minutes for build, qualification, audit and -SBOM. That leaves about 2x headroom. +`release-verify.yml` sets `timeout-minutes: 25`, derived from observed hosted- +runner timings rather than an estimate: + +| Phase | Observed | +|---|---| +| Correctness suite (`-n auto`, `not perf`) | 407s | +| Install, lint, compile, schema check, static lint, dependency audit | ~40s | +| Source build, artifact download, signature + qualification verification, isolated SBOM install | ~2 min | + +A healthy run lands near 10 minutes, so 25 leaves roughly 2.5x headroom. The +suite dominates; the SBOM step is the second largest because it installs the +wheel's runtime closure into a fresh environment. After any change that materially grows the suite, read the actual job duration -from a rehearsal run and reset the timeout to roughly twice it. Do not raise it -in response to a single timeout without checking what got slower. +from a rehearsal run and reset the timeout to roughly 2.5x it. Do not raise it +in response to a single timeout without checking what got slower — a timeout +that appears without a corresponding change in these phases is more likely a +hung step than an undersized budget. ## Cutting the release From bc4d6c78ecc20fc372de3fd9040690b62af3b9a2 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Sun, 9 Aug 2026 14:29:10 -0700 Subject: [PATCH 3/8] docs(release): descope the pre-1.0 evidence-bar decision from v0.16.0 Product decision 2026-08-09: #341 is P2 ("queued; valuable but not blocking") and no longer in the v0.16.0 milestone. Restates the brief accordingly, and is explicit that descoping changed what the milestone tracks rather than what the pipeline enforces: a `v*` tag still fails closed at the qualification step unless a signed artifact satisfies the 100-case bar, so publishing still needs either that artifact or an approved alternative policy. Co-Authored-By: Claude Opus 5 --- docs/release-evidence-policy-decision.md | 30 +++++++++++++++++------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/release-evidence-policy-decision.md b/docs/release-evidence-policy-decision.md index 800518f2..6a4251fa 100644 --- a/docs/release-evidence-policy-decision.md +++ b/docs/release-evidence-policy-decision.md @@ -1,7 +1,9 @@ # Decision Brief: The Evidence Bar for Pre-1.0 Tags -**Status: open. Awaiting a named human product/security owner.** -Tracked by [#341](https://github.com/ThreeMoonsLab/agents-shipgate/issues/341). +**Status: open, not milestone-blocking.** Tracked by +[#341](https://github.com/ThreeMoonsLab/agents-shipgate/issues/341) (P2 — +"queued; valuable but not blocking"), outside the v0.16.0 milestone by product +decision on 2026-08-09. This brief exists to make the decision cheap to take, not to take it. No route is selected here, and nothing in this document changes the enforced policy: the @@ -10,6 +12,14 @@ release verifier still requires the full 100-case production-beta artifact. Do not infer the choice from the tag, and do not rename the current policy after the fact. +> **What "not blocking" does and does not mean.** Descoping #341 changed what +> the milestone tracks, not what the pipeline enforces. A `v*` tag still fails +> closed at **Verify production qualification and exact wheel binding** unless a +> signed artifact satisfies the 100-case bar. Shipping a tag therefore still +> needs either that artifact (Route 1) or an approved alternative policy +> (Route 2) — the difference is that neither is now treated as a v0.16.0 +> deliverable. + ## Why a decision is required `v0.16.0` is the first tag that will run the safety-qualification gate @@ -31,9 +41,13 @@ measurement, but they are not interchangeable with qualification cases: qualification additionally binds adjudicated labels and a terminal verifier receipt per case. -So `v0.16.0` is blocked on a policy question, not on engineering. The five -engineering workstreams (#342, #343, #344, #355, #356) are complete and -independent of this decision. +This is a policy question, not an engineering one. The five engineering +workstreams (#342, #343, #344, #355, #356) are complete and independent of it, +which is why the milestone no longer waits on this decision. + +What remains true is that the qualification gate is a hard precondition for any +tag. Until a conforming artifact exists, the practical options are to keep the +release unpublished, or to take one of the two routes below deliberately. ## Where the bar is defined @@ -57,10 +71,10 @@ it. ### Route 1 — retain the 100-case production-beta policy -`v0.16.0` stays blocked until a separate corpus-delivery issue produces the +No tag publishes until a separate corpus-delivery issue produces the independently labelled, adjudicated, receipt-bound artifact. -- **Cost:** the release is gated on a substantial data effort — roughly 68 more +- **Cost:** publication is gated on a substantial data effort — roughly 68 more adjudicated cases with receipts, spread to satisfy 28 strata and the origin minimum. - **Benefit:** the shipped claim is exactly the claim that was designed. No @@ -76,7 +90,7 @@ at 1.0. - **Cost:** a new tier in the schema vocabulary, a second requirements constructor, verifier branching, and doc updates. Adds a surface that must later be retired. -- **Benefit:** unblocks `v0.16.0` on a stated, auditable basis rather than an +- **Benefit:** allows a `0.x` tag to publish on a stated, auditable basis rather than an implied one. - **Non-negotiable regardless of the numbers chosen:** - zero unsafe auto-passes, per profile and overall From a6eddfde3ccdeb9e1159916c523ed3a26b1f41a6 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Sun, 9 Aug 2026 18:30:04 -0700 Subject: [PATCH 4/8] fix(release): harden the publication transaction against the PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all ten findings on #359. P1 — a moved tag could make the pipeline build one commit while claiming another: verification now runs against the immutable `github.sha`, keys every binding to the SHA it resolved with `git rev-parse HEAD`, re-peels the tag against the remote before each irreversible step, and creates the release with `--verify-tag`. P1 — signer identity and OIDC issuer were mutable variables, so one actor could substitute fabricated qualification evidence and replace the allowlist that authenticates it in a single unreviewed step. They move to `.github/release-trust-roots.json` (reviewed code, fail-closed while unset); only content-addressed locations stay in variables. P1 — the token-bearing job installed the editable project and ranged dev extras before verifying anything, so a compromised dependency could request the PyPI OIDC token. Publication is now split so write and OIDC are never held together: `publish` has `id-token` only, checks out no project code, and installs a hash-locked toolchain with `--require-hashes`. The publication-side scripts are standard-library only to make that possible. P1 — rehearsal was mandatory only in prose. `stage` now requires a successful rehearsal run at the same source SHA whose candidate manifest is byte-identical, and every rehearsal proves the provenance gate fails closed by injecting a tampered wheel and asserting rejection. P1 — `verify-manifest` treated an empty `--expected-sha256` as "no check", so a redacted job output silently disabled the only trusted-channel binding. The flag is now required and must be 64 lowercase hex. P1 — `published_identical` accepted digest membership, so a matching wheel beside a divergent sdist, a renamed file, or a yanked record passed. It now requires exactly one unyanked `bdist_wheel` with the expected filename and digest. P1 — any existing release was clobbered before PyPI divergence was checked. The index is classified first, and only drafts are ever mutated; a published release is verified and left untouched. P2 — the handoff is closed-world (extra files rejected, non-regular entries refused, explicit upload allowlist instead of `dist/*`); the SBOM reuses CycloneDX's own root component and ref instead of inventing one, so the subject is described once and keeps its dependency node; and wheel basenames are parsed and compared before the byte fast path, so identical bytes under a different compatibility tag no longer pass. Co-Authored-By: Claude Opus 5 --- .github/release-trust-roots.json | 26 + .github/workflows/release-verify.yml | 94 +++- .github/workflows/release.yml | 281 ++++++++-- CHANGELOG.md | 36 +- constraints/release-publish.in | 4 + constraints/release-publish.txt | 537 +++++++++++++++++++ docs/distribution.md | 21 +- docs/release-runbook.md | 139 +++-- scripts/_release_support.py | 81 +++ scripts/release_publication.py | 207 ++++++-- scripts/release_sbom.py | 134 ++++- scripts/verify_wheel_provenance.py | 41 ++ tests/test_release_pipeline.py | 588 ++++++++++++++++----- tests/test_safety_qualification_release.py | 2 +- 14 files changed, 1895 insertions(+), 296 deletions(-) create mode 100644 .github/release-trust-roots.json create mode 100644 constraints/release-publish.in create mode 100644 constraints/release-publish.txt create mode 100644 scripts/_release_support.py diff --git a/.github/release-trust-roots.json b/.github/release-trust-roots.json new file mode 100644 index 00000000..14bb554f --- /dev/null +++ b/.github/release-trust-roots.json @@ -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" +} diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index 9ccba595..21d77949 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -74,6 +74,7 @@ jobs: outputs: version: ${{ steps.candidate.outputs.version }} release_tag: ${{ steps.candidate.outputs.release_tag }} + source_sha: ${{ steps.candidate.outputs.source_sha }} wheel_filename: ${{ steps.qualified.outputs.wheel_filename }} wheel_sha256: ${{ steps.handoff.outputs.wheel_sha256 }} manifest_sha256: ${{ steps.handoff.outputs.manifest_sha256 }} @@ -92,6 +93,11 @@ jobs: REQUESTED_TAG: ${{ inputs.release_tag }} run: | set -euo pipefail + # The commit actually checked out, resolved after checkout rather than + # taken from the triggering event. Callers pass an immutable SHA, but + # this is what every downstream binding is keyed to, so it is measured + # here instead of assumed. + source_sha="$(git rev-parse HEAD)" version="$(sed -n 's/^version = "\(.*\)"$/\1/p' pyproject.toml | head -n 1)" if [ -z "${version}" ]; then echo "::error::Could not read version from pyproject.toml." @@ -106,27 +112,58 @@ jobs: echo "::error::Tag ${tag} does not match pyproject.toml version ${version}; refusing to release." exit 1 fi - echo "version=${version}" >> "${GITHUB_OUTPUT}" - echo "release_tag=${tag}" >> "${GITHUB_OUTPUT}" - echo "OK: ${tag} matches pyproject.toml (${version})." + { + echo "version=${version}" + echo "release_tag=${tag}" + echo "source_sha=${source_sha}" + } >> "${GITHUB_OUTPUT}" + echo "OK: ${tag} matches pyproject.toml (${version}) at ${source_sha}." - - name: Require protected safety qualification inputs + - name: Load qualification trust roots from reviewed code + # The signer identity and OIDC issuer are the allowlist that + # authenticates the qualification artifact, so they live in a committed, + # reviewed file rather than in variables. If they were mutable + # configuration, one actor could substitute fabricated qualification + # evidence *and* replace the identity that vouches for it, in a single + # step with no diff to review — and source-to-wheel binding would not + # notice, because that attack reuses the legitimate wheel and forges + # only the safety claims about it. + id: trust_roots + run: | + set -euo pipefail + roots=".github/release-trust-roots.json" + identity="$(python -c "import json,sys; sys.stdout.write(json.load(open('${roots}'))['signer_identity'])")" + issuer="$(python -c "import json,sys; sys.stdout.write(json.load(open('${roots}'))['oidc_issuer'])")" + for pair in "signer_identity=${identity}" "oidc_issuer=${issuer}"; do + value="${pair#*=}" + if [ -z "${value}" ] || [ "${value}" = "CHANGE_ME" ]; then + echo "::error::${roots} leaves ${pair%%=*} unset; configure the qualification trust root before releasing." + exit 1 + fi + done + case "${issuer}" in + https://*) ;; + *) echo "::error::Qualification OIDC issuer must use HTTPS."; exit 1 ;; + esac + { + echo "signer_identity=${identity}" + echo "oidc_issuer=${issuer}" + } >> "${GITHUB_OUTPUT}" + echo "OK: qualification trust roots loaded from ${roots}." + + - name: Require configured qualification artifact locations env: QUALIFIED_WHEEL_URL: ${{ vars.SAFETY_QUALIFICATION_WHEEL_URL }} QUALIFIED_WHEEL_FILENAME: ${{ vars.SAFETY_QUALIFICATION_WHEEL_FILENAME }} QUALIFICATION_JSON_URL: ${{ vars.SAFETY_QUALIFICATION_JSON_URL }} QUALIFICATION_BUNDLE_URL: ${{ vars.SAFETY_QUALIFICATION_SIGSTORE_BUNDLE_URL }} - QUALIFICATION_SIGNER_IDENTITY: ${{ vars.SAFETY_QUALIFICATION_SIGNER_IDENTITY }} - QUALIFICATION_OIDC_ISSUER: ${{ vars.SAFETY_QUALIFICATION_OIDC_ISSUER }} run: | missing=0 for name in \ QUALIFIED_WHEEL_URL \ QUALIFIED_WHEEL_FILENAME \ QUALIFICATION_JSON_URL \ - QUALIFICATION_BUNDLE_URL \ - QUALIFICATION_SIGNER_IDENTITY \ - QUALIFICATION_OIDC_ISSUER + QUALIFICATION_BUNDLE_URL do if [ -z "${!name:-}" ]; then echo "::error::Repository variable ${name} is required; refusing to release." @@ -207,10 +244,10 @@ jobs: echo "QUALIFIED_WHEEL_FILENAME=${QUALIFIED_WHEEL_FILENAME}" >> "${GITHUB_ENV}" echo "wheel_filename=${QUALIFIED_WHEEL_FILENAME}" >> "${GITHUB_OUTPUT}" - - name: Verify configured qualification signature + - name: Verify qualification signature against the committed trust root env: - QUALIFICATION_SIGNER_IDENTITY: ${{ vars.SAFETY_QUALIFICATION_SIGNER_IDENTITY }} - QUALIFICATION_OIDC_ISSUER: ${{ vars.SAFETY_QUALIFICATION_OIDC_ISSUER }} + QUALIFICATION_SIGNER_IDENTITY: ${{ steps.trust_roots.outputs.signer_identity }} + QUALIFICATION_OIDC_ISSUER: ${{ steps.trust_roots.outputs.oidc_issuer }} run: | sigstore verify identity \ --bundle qualified-dist/safety-qualification.sigstore.json \ @@ -235,13 +272,41 @@ jobs: # Byte equality is required; a container-metadata-only difference is # reported as a reproducibility gap and still fails, because the # weaker bar must never be taken silently. + env: + SOURCE_SHA: ${{ steps.candidate.outputs.source_sha }} run: | python scripts/verify_wheel_provenance.py \ --built source-build/*.whl \ --qualified "${QUALIFIED_WHEEL}" \ - --source-commit "${GITHUB_SHA}" \ + --source-commit "${SOURCE_SHA}" \ --report provenance.json + - name: Prove the provenance gate fails closed + # Executable version of the runbook's "rehearse a failure path" step. + # Corrupts a *copy* of the qualified wheel and asserts the gate rejects + # it, so every rehearsal demonstrates the control actually stops a + # substituted artifact instead of asserting it in prose. Runs only in + # rehearsal mode; a real release must not spend time on drills. + if: inputs.mode == 'rehearsal' + run: | + set -euo pipefail + cp -- "${QUALIFIED_WHEEL}" fault-injected.whl + python - <<'PY' + import zipfile + + with zipfile.ZipFile("fault-injected.whl", "a") as archive: + archive.writestr("agents_shipgate/_fault_injection.py", "raise SystemExit\n") + PY + if python scripts/verify_wheel_provenance.py \ + --built source-build/*.whl \ + --qualified fault-injected.whl > fault-injection.log 2>&1; then + echo "::error::Fault injection was accepted; the provenance gate does not fail closed." + exit 1 + fi + rm -f fault-injected.whl + echo "OK: provenance gate rejected a tampered wheel:" + cat fault-injection.log + - name: Lint run: python -m ruff check . @@ -298,6 +363,7 @@ jobs: id: handoff env: RELEASE_TAG: ${{ steps.candidate.outputs.release_tag }} + SOURCE_SHA: ${{ steps.candidate.outputs.source_sha }} run: | set -euo pipefail cp -- "${QUALIFIED_WHEEL}" "candidate/${QUALIFIED_WHEEL_FILENAME}" @@ -308,7 +374,7 @@ jobs: cmp -- "${QUALIFIED_WHEEL}" "candidate/${QUALIFIED_WHEEL_FILENAME}" python scripts/release_publication.py manifest \ --tag "${RELEASE_TAG}" \ - --source-commit "${GITHUB_SHA}" \ + --source-commit "${SOURCE_SHA}" \ --wheel "candidate/${QUALIFIED_WHEEL_FILENAME}" \ --asset candidate/agents-shipgate-sbom.json \ --asset candidate/safety-qualification.json \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5b0bf8e..48d673f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,10 +5,10 @@ on: tags: - "v*" -# No ambient authority. Each job requests the minimum it needs: verification -# gets read-only via the reusable workflow, and only `publish` holds the -# `contents: write` and `id-token: write` that can mutate anything outside -# this run. +# No ambient authority. Each job requests the minimum it needs, and the two +# dangerous capabilities are never held together: `publish` can mint a PyPI +# token but cannot write to the repository, and `stage`/`finalize` can write to +# the repository but cannot mint a token. permissions: {} # Serialised across the PyPI project rather than per tag. Two different tags @@ -27,39 +27,77 @@ jobs: contents: read uses: ./.github/workflows/release-verify.yml with: - ref: ${{ github.ref }} + # The immutable SHA of the push event, never `github.ref`. A symbolic ref + # is re-resolved by the checkout action, so a tag moved between the event + # and the checkout would build commit B while provenance recorded A. + ref: ${{ github.sha }} release_tag: ${{ github.ref_name }} mode: release - publish: - name: Publish candidate + stage: + name: Stage release needs: verify runs-on: ubuntu-latest - timeout-minutes: 20 - # The human gate sits here rather than on verification. Reviewers now - # approve *after* the readiness summary exists, instead of approving a run - # whose evidence has not been produced yet. - environment: pypi + timeout-minutes: 15 permissions: contents: write - id-token: write - + actions: read + outputs: + should_publish: ${{ steps.index.outputs.should_publish }} + index_state: ${{ steps.index.outputs.state }} steps: - - name: Checkout + - name: Checkout the verified source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: + ref: ${{ needs.verify.outputs.source_sha }} persist-credentials: false - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 with: python-version: "3.12" - cache: pip - - name: Install publication tooling + - name: Confirm the tag still points at the verified commit + # Guards a tag moved (or deleted and recreated) after verification. + # `^{}` peels annotated tags to the commit they name. + env: + RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} + SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} + run: | + set -euo pipefail + peeled="$(git ls-remote "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${RELEASE_TAG}^{}" | cut -f1)" + if [ -z "${peeled}" ]; then + peeled="$(git ls-remote "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${RELEASE_TAG}" | cut -f1)" + fi + if [ "${peeled}" != "${SOURCE_SHA}" ]; then + echo "::error::Tag ${RELEASE_TAG} now points at ${peeled:-}, not the verified ${SOURCE_SHA}." + exit 1 + fi + echo "OK: ${RELEASE_TAG} still resolves to ${SOURCE_SHA}." + + - name: Require a successful rehearsal of this exact candidate + # #355 made rehearsal mandatory in the runbook; this makes it a + # precondition the pipeline enforces. Matching on `head_sha` binds both + # the source and the workflow revision, because they are the same tree. + # Comparing the rehearsed manifest byte-for-byte additionally binds + # candidate identity, so a qualification artifact swapped between the + # rehearsal and the tag is caught here. + env: + GH_TOKEN: ${{ github.token }} + SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} + ARTIFACT_NAME: ${{ needs.verify.outputs.artifact_name }} run: | - python -m pip install -e ".[dev]" - python -m pip install "uv==0.11.7" + set -euo pipefail + run_id="$(gh api \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/release-rehearsal.yml/runs?head_sha=${SOURCE_SHA}&status=success&per_page=100" \ + --jq '.workflow_runs[0].id // empty')" + if [ -z "${run_id}" ]; then + echo "::error::No successful Release Rehearsal run for ${SOURCE_SHA}. Rehearse the candidate before tagging; see docs/release-runbook.md." + exit 1 + fi + echo "Rehearsal run: ${run_id}" + gh run download "${run_id}" --name "${ARTIFACT_NAME}" --dir rehearsed + echo "REHEARSAL_RUN_ID=${run_id}" >> "${GITHUB_ENV}" - name: Download verified candidate uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 @@ -70,13 +108,18 @@ jobs: - name: Verify the candidate handoff # The expected digest arrives through the job-output channel, not the # artifact store, so swapping a file in the store — or rewriting the - # manifest to agree with the swap — breaks one of the links. + # manifest to agree with the swap — breaks one of the links. The check + # is closed-world: an unlisted file in `dist/` is rejected rather than + # silently uploaded. env: MANIFEST_SHA256: ${{ needs.verify.outputs.manifest_sha256 }} run: | + set -euo pipefail python scripts/release_publication.py verify-manifest \ --manifest dist/candidate-manifest.json \ --expected-sha256 "${MANIFEST_SHA256}" + cmp -- rehearsed/candidate-manifest.json dist/candidate-manifest.json + echo "OK: the tagged candidate is byte-identical to the rehearsed one." - name: Re-verify the wheel-scoped SBOM binding env: @@ -86,22 +129,28 @@ jobs: --wheel "dist/${WHEEL_FILENAME}" \ --sbom dist/agents-shipgate-sbom.json - - name: Sign release artifacts + - name: Classify index state + id: index + # Runs *before* anything mutates the GitHub Release, so a divergent + # version fails with both registries untouched. env: WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} run: | - sigstore sign --output-directory dist --overwrite \ - "dist/${WHEEL_FILENAME}" \ - dist/agents-shipgate-sbom.json + python scripts/release_publication.py pypi-state \ + --wheel "dist/${WHEEL_FILENAME}" \ + --github-output "${GITHUB_OUTPUT}" - - name: Create draft GitHub release - # Created before the immutable PyPI upload and carrying the - # authoritative assets, so a failure anywhere after publication leaves - # a discoverable draft to finish from rather than a published version - # with nothing attached. + - name: Create or repair the draft release + # A draft carrying the authoritative assets must exist before the + # immutable PyPI upload, so a later failure leaves something to finish + # from. Only drafts are ever mutated: an already-published release is + # verified and left alone, because clobbering its assets on a re-run + # would replace public bytes that PyPI can no longer be made to match. env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} + WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} + MANIFEST_SHA256: ${{ needs.verify.outputs.manifest_sha256 }} run: | set -euo pipefail # `v1.2.3` is a final release; anything else (v0.16.0b7, rc, a) is a @@ -112,36 +161,165 @@ jobs: maturity="--prerelease" fi echo "RELEASE_MATURITY=${maturity}" >> "${GITHUB_ENV}" - if gh release view "${RELEASE_TAG}" > /dev/null 2>&1; then - echo "Release ${RELEASE_TAG} already exists; re-uploading assets to the existing draft." - gh release upload "${RELEASE_TAG}" dist/* --clobber - else - gh release create "${RELEASE_TAG}" dist/* \ + + assets=( + "dist/${WHEEL_FILENAME}" + dist/agents-shipgate-sbom.json + dist/safety-qualification.json + dist/safety-qualification.sigstore.json + dist/provenance.json + dist/candidate-manifest.json + ) + + if ! gh release view "${RELEASE_TAG}" > /dev/null 2>&1; then + gh release create "${RELEASE_TAG}" "${assets[@]}" \ --draft \ + --verify-tag \ "${maturity}" \ --title "${RELEASE_TAG}" \ --notes "Agents Shipgate ${RELEASE_TAG}" + echo "Created draft release ${RELEASE_TAG}." + exit 0 fi - - name: Classify index state - id: index + if [ "$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft)" = "true" ]; then + gh release upload "${RELEASE_TAG}" "${assets[@]}" --clobber + echo "Repaired existing draft ${RELEASE_TAG}." + exit 0 + fi + + # Published already. Prove it carries exactly these bytes, then stop + # touching it. Signature bundles are produced after staging, so they + # are the only additional files permitted. + mkdir -p published + gh release download "${RELEASE_TAG}" --dir published --clobber + python scripts/release_publication.py verify-manifest \ + --manifest published/candidate-manifest.json \ + --expected-sha256 "${MANIFEST_SHA256}" \ + --directory published \ + --allow "${WHEEL_FILENAME}.sigstore.json" \ + --allow agents-shipgate-sbom.json.sigstore.json + echo "Release ${RELEASE_TAG} is already published with the verified assets; leaving it untouched." + + publish: + name: Publish to PyPI + needs: [verify, stage] + runs-on: ubuntu-latest + timeout-minutes: 15 + # The human gate sits here, on the one irreversible action, and after the + # readiness summary exists — reviewers approve with evidence rather than + # approving a run whose evidence has not been produced yet. + environment: pypi + # Deliberately *only* `id-token`. This job can mint a PyPI token, so it + # holds no repository write, checks out no project code, and installs + # nothing but the hash-locked toolchain in constraints/release-publish.txt. + permissions: + id-token: write + steps: + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.12" + + - name: Install the hash-locked publication toolchain + env: + LOCKFILE_URL: https://raw.githubusercontent.com/${{ github.repository }}/${{ needs.verify.outputs.source_sha }}/constraints/release-publish.txt + run: | + set -euo pipefail + # Fetched by immutable commit SHA rather than checked out, so no + # project code lands in the token-bearing job. + curl --fail --location --proto '=https' --proto-redir '=https' \ + --output release-publish.txt "${LOCKFILE_URL}" + python -m pip install --require-hashes --requirement release-publish.txt + + - name: Confirm the tag still points at the verified commit + env: + RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} + SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} + run: | + set -euo pipefail + peeled="$(git ls-remote "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${RELEASE_TAG}^{}" | cut -f1)" + if [ -z "${peeled}" ]; then + peeled="$(git ls-remote "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${RELEASE_TAG}" | cut -f1)" + fi + if [ "${peeled}" != "${SOURCE_SHA}" ]; then + echo "::error::Tag ${RELEASE_TAG} now points at ${peeled:-}, not the verified ${SOURCE_SHA}." + exit 1 + fi + + - name: Download verified candidate + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: ${{ needs.verify.outputs.artifact_name }} + path: dist + + - name: Confirm the wheel digest verification approved env: WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} + WHEEL_SHA256: ${{ needs.verify.outputs.wheel_sha256 }} run: | - python scripts/release_publication.py pypi-state \ - --wheel "dist/${WHEEL_FILENAME}" \ - --github-output "${GITHUB_OUTPUT}" + set -euo pipefail + echo "${WHEEL_SHA256} dist/${WHEEL_FILENAME}" | sha256sum --check --strict + + - name: Sign release artifacts + env: + WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} + run: | + sigstore sign --output-directory dist --overwrite \ + "dist/${WHEEL_FILENAME}" \ + dist/agents-shipgate-sbom.json - name: Publish to PyPI with Trusted Publishing # Skipped when the index already holds these exact bytes, which is what - # a re-run after a post-publication failure looks like. The state check - # hard-fails if the version exists with *different* bytes, so this can - # never quietly succeed against a divergent artifact. - if: steps.index.outputs.should_publish == 'true' + # a re-run after a post-publication failure looks like. `stage` + # hard-fails if the version exists with anything else, so this can never + # quietly succeed against a divergent artifact. + if: needs.stage.outputs.should_publish == 'true' env: WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} run: uv publish --trusted-publishing always "dist/${WHEEL_FILENAME}" + - name: Upload signatures for finalisation + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: release-signatures-${{ needs.verify.outputs.release_tag }} + path: dist/*.sigstore.json + if-no-files-found: error + retention-days: 14 + + finalize: + name: Finalise release + needs: [verify, stage, publish] + runs-on: ubuntu-latest + timeout-minutes: 15 + # Repository write, and no `id-token`: a dependency compromised here cannot + # reach PyPI. + permissions: + contents: write + steps: + - name: Checkout the verified source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ needs.verify.outputs.source_sha }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.12" + + - name: Download verified candidate + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: ${{ needs.verify.outputs.artifact_name }} + path: dist + + - name: Download signatures + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: release-signatures-${{ needs.verify.outputs.release_tag }} + path: signatures + - name: Confirm the index holds the verified bytes env: WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} @@ -149,6 +327,12 @@ jobs: python scripts/release_publication.py pypi-state \ --wheel "dist/${WHEEL_FILENAME}" | tee /dev/stderr | grep -q "published_identical" + - name: Attach signatures + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} + run: gh release upload "${RELEASE_TAG}" signatures/*.sigstore.json --clobber + - name: Validate release assets env: GH_TOKEN: ${{ github.token }} @@ -159,7 +343,9 @@ jobs: attached="$(gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name')" for required in \ "${WHEEL_FILENAME}" \ + "${WHEEL_FILENAME}.sigstore.json" \ agents-shipgate-sbom.json \ + agents-shipgate-sbom.json.sigstore.json \ safety-qualification.json \ provenance.json \ candidate-manifest.json @@ -175,4 +361,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} - run: gh release edit "${RELEASE_TAG}" --draft=false "${RELEASE_MATURITY}" + run: | + set -euo pipefail + if [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + maturity="--latest" + else + maturity="--prerelease" + fi + gh release edit "${RELEASE_TAG}" --draft=false "${maturity}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 20eddbdb..684e0749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,17 +29,39 @@ (`contents: read`, no OIDC) that hands off a content-addressed candidate bundle; the manifest digest travels through the job-output channel, so swapping an artifact — or rewriting the manifest to agree with the swap — is - detected. Publication holds the only `contents: write` and `id-token: write`, - creates a **draft** GitHub Release carrying the authoritative assets before - uploading to PyPI, and finalizes only after asset validation. The upload is - idempotence-aware: `scripts/release_publication.py pypi-state` classifies the - index as `absent`, `published_identical` (a re-run completing an interrupted - transaction, which skips the upload), or `published_divergent` (always - fatal). An unreachable index is never read as permission to upload. Release + detected, and the check is closed-world so an unlisted file cannot ride + along. Write and OIDC authority are never held by the same job: the PyPI + publisher holds `id-token: write` alone, checks out no project code, and + installs only a hash-locked toolchain, so a compromised dependency cannot + both mint a token and rewrite the repository. A **draft** GitHub Release + carrying the authoritative assets exists before the upload, and finalization + happens only after asset validation. The upload is idempotence-aware: + `scripts/release_publication.py pypi-state` classifies the index as `absent`, + `published_identical` — requiring *exactly one* unyanked wheel with the + expected filename and digest, so a divergent sdist or second wheel is not + mistaken for a completed transaction — or `published_divergent` (always + fatal). An unreachable index is never read as permission to upload, and an + already-published release is verified and left untouched rather than + clobbered. Release concurrency is serialized across the PyPI project rather than per tag, with `cancel-in-progress: false`. The `pypi` reviewer gate moved to publication, so reviewers approve *after* the readiness summary exists instead of approving a run whose evidence has not been produced yet. +- **The qualification signer identity is reviewed code, and a release candidate + must have been rehearsed.** The identity and OIDC issuer that authenticate the + signed qualification artifact now live in `.github/release-trust-roots.json` + rather than in variables: an actor able to set variables could otherwise + substitute fabricated evidence *and* replace the allowlist that vouches for + it in one unreviewed step — an attack source-to-wheel binding cannot see, + because it reuses the legitimate wheel and forges only the claims about it. + Only content-addressed locations stay mutable. Verification also runs against + the immutable event SHA rather than the symbolic tag ref, and the tag is + re-peeled against the remote before each irreversible step, so a moved tag + cannot make the pipeline build one commit while claiming another. + Publication additionally requires a successful rehearsal at the same source + SHA whose candidate manifest is byte-identical, and every rehearsal now + proves the provenance gate fails closed by injecting a tampered wheel and + asserting it is rejected. - **The signed SBOM now describes the shipped wheel instead of the CI machine.** The workflow installed `.[dev]` and ran `cyclonedx-py environment`, inventorying pytest, ruff, twine, Sigstore, and the CycloneDX diff --git a/constraints/release-publish.in b/constraints/release-publish.in new file mode 100644 index 00000000..898001b4 --- /dev/null +++ b/constraints/release-publish.in @@ -0,0 +1,4 @@ +# Direct requirements for the publication jobs; see release-publish.txt for the +# hash-locked resolution actually installed. +sigstore==4.5.0 +uv==0.11.7 diff --git a/constraints/release-publish.txt b/constraints/release-publish.txt new file mode 100644 index 00000000..a962db80 --- /dev/null +++ b/constraints/release-publish.txt @@ -0,0 +1,537 @@ +# Hash-locked toolchain for the publication jobs. +# +# The jobs that hold `id-token: write` can mint a PyPI Trusted Publishing +# token, so everything they install is inside the blast radius of a compromised +# dependency. They therefore install *only* this closure — never the editable +# project and never the ranged `dev` extra — and install it with +# `--require-hashes`, so a compromised or substituted artifact fails to install +# rather than executing next to the token. +# +# Regenerate with: +# +# uv pip compile --universal --generate-hashes --python-version 3.12 \ +# constraints/release-publish.in -o constraints/release-publish.txt +# +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + # via pydantic +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via requests +cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via cryptography +charset-normalizer==3.4.9 \ + --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ + --hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \ + --hash=sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c \ + --hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \ + --hash=sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5 \ + --hash=sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833 \ + --hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \ + --hash=sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99 \ + --hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \ + --hash=sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec \ + --hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \ + --hash=sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4 \ + --hash=sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a \ + --hash=sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3 \ + --hash=sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2 \ + --hash=sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a \ + --hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \ + --hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \ + --hash=sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419 \ + --hash=sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84 \ + --hash=sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da \ + --hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \ + --hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \ + --hash=sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381 \ + --hash=sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29 \ + --hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \ + --hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \ + --hash=sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe \ + --hash=sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29 \ + --hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \ + --hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \ + --hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \ + --hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \ + --hash=sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94 \ + --hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \ + --hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \ + --hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \ + --hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \ + --hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \ + --hash=sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1 \ + --hash=sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15 \ + --hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \ + --hash=sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993 \ + --hash=sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4 \ + --hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \ + --hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \ + --hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \ + --hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \ + --hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \ + --hash=sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d \ + --hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \ + --hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \ + --hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \ + --hash=sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db \ + --hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \ + --hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \ + --hash=sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee \ + --hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \ + --hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \ + --hash=sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f \ + --hash=sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9 \ + --hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \ + --hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \ + --hash=sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8 \ + --hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \ + --hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \ + --hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \ + --hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \ + --hash=sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616 \ + --hash=sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d \ + --hash=sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba \ + --hash=sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2 \ + --hash=sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b \ + --hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \ + --hash=sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b \ + --hash=sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209 \ + --hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \ + --hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \ + --hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \ + --hash=sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a \ + --hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \ + --hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \ + --hash=sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b \ + --hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \ + --hash=sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5 \ + --hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \ + --hash=sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9 \ + --hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \ + --hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \ + --hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \ + --hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \ + --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ + --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 + # via requests +cryptography==50.0.0 \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 + # via + # pyopenssl + # rfc3161-client + # sigstore +dnspython==2.8.0 \ + --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \ + --hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f + # via email-validator +email-validator==2.3.0 \ + --hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \ + --hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426 + # via pydantic +id==1.6.1 \ + --hash=sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069 \ + --hash=sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca + # via sigstore +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # email-validator + # requests +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a + # via rich +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba + # via markdown-it-py +platformdirs==4.11.1 \ + --hash=sha256:2efd27d363e8dd2e661639ffb398865a5e0a46442a11d266bf375a0e0c10e386 \ + --hash=sha256:bb1af68078f25e2f3e111e2d43b8d536df41b73c8a684b40bb018223b66fae27 + # via sigstore +pyasn1==0.6.4 \ + --hash=sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81 \ + --hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b + # via sigstore +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pydantic==2.13.4 \ + --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ + --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 + # via + # sigstore + # sigstore-models + # sigstore-rekor-types +pydantic-core==2.46.4 \ + --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \ + --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \ + --hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \ + --hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \ + --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \ + --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \ + --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \ + --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \ + --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \ + --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \ + --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \ + --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \ + --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \ + --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \ + --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \ + --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \ + --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \ + --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \ + --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \ + --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \ + --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \ + --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \ + --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \ + --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \ + --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \ + --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \ + --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \ + --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \ + --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \ + --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \ + --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \ + --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \ + --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \ + --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \ + --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \ + --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \ + --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \ + --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \ + --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \ + --hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \ + --hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \ + --hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \ + --hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \ + --hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \ + --hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \ + --hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \ + --hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \ + --hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \ + --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \ + --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \ + --hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \ + --hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \ + --hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \ + --hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \ + --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \ + --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \ + --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \ + --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \ + --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \ + --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \ + --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \ + --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \ + --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \ + --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \ + --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \ + --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \ + --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \ + --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \ + --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \ + --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \ + --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \ + --hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \ + --hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \ + --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \ + --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \ + --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \ + --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \ + --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \ + --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \ + --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \ + --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \ + --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \ + --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \ + --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \ + --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \ + --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \ + --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \ + --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \ + --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \ + --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \ + --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \ + --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \ + --hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \ + --hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \ + --hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \ + --hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \ + --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \ + --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \ + --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \ + --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \ + --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \ + --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \ + --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \ + --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \ + --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \ + --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \ + --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \ + --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \ + --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \ + --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \ + --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \ + --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \ + --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \ + --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \ + --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \ + --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \ + --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \ + --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \ + --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \ + --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae + # via pydantic +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + # via rich +pyjwt==2.13.0 \ + --hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \ + --hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728 + # via sigstore +pyopenssl==26.4.0 \ + --hash=sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7 \ + --hash=sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c + # via sigstore +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed + # via sigstore +rfc3161-client==1.0.8 \ + --hash=sha256:29cebab8dadec88b85eac43505db186749e23ddf1c7699ffa08f7cbdc86a52fe \ + --hash=sha256:38c5c278c7d4605e9c166a332c13b286475730b750109235b547f7d1b21c5c6f \ + --hash=sha256:3951db9573e4f6b4a1a62ad4f0074683610714625bf50c010739ffa568f23beb \ + --hash=sha256:4bda5a2bc6947c16b6f8df90ff0e99cb333d78ab1465517f637d313d75703651 \ + --hash=sha256:4bdb12618f98ee634d3625208f4f1c3cdde2306a8187a7416bfa47d45cad3dba \ + --hash=sha256:51adc82dbd04d2b88e3a17f524f0e57d0270d7276887a5800c81833f79fb4f4a \ + --hash=sha256:55cd9366f20dcea8dc65f93b08d12607c071015d2f5b5d24129128f04643a77d \ + --hash=sha256:5c1889d6bae269dc0f1e418f82e554b413066b5f8dd864f9367cf1ffb3d0a312 \ + --hash=sha256:7f8b82c97c1935a09376591b45bd81aa57a4232f4d446eee3a44c025ef7c4d5a \ + --hash=sha256:9826227dc04e1a86f2598f9c1829f078afc35f558987df77578ea1508d1460a1 \ + --hash=sha256:9d382372e7fdfde592584f985fb1063d1506ae0573df45fcb2b41e2ed82e3431 \ + --hash=sha256:d3c25311c67a7daeef990fb5b94eaed706135c6a6fd98c6e382bbc857faa4214 \ + --hash=sha256:e95ca8a64fddfdd639e09e48bab4722f31b26ce099067ad2bb8e85f88fcc707b + # via sigstore +rfc8785==0.1.4 \ + --hash=sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48 \ + --hash=sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da + # via sigstore +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ + --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 + # via sigstore +securesystemslib==1.4.0 \ + --hash=sha256:a0743a3d978cf26e98a70a57e3fbd5a18e0a74c20cabe615f6a55b02ef0272b3 \ + --hash=sha256:faea87be0f9c4b4277a5fa1b54bf9bfd807be9a94ab11be6c557dc8b75c43285 + # via tuf +sigstore==4.5.0 \ + --hash=sha256:020d3e07f622b2916bf453e66ff6ff0711e1fdc5ab69e8bd8902f71d9fcb316f \ + --hash=sha256:f045b207f2e12605cf775ec38e89c5eda625d71ffa7830477db65e47ec2bc8b2 + # via -r constraints/release-publish.in +sigstore-models==0.0.6 \ + --hash=sha256:5201a68f4d7d0f8bec1e2f4378eb646b084c52609a4e31db8c385095fff68b2e \ + --hash=sha256:c766c09470c2a7e8a4a333c893f07e2001c56a3ff1757b1a246119f53169a849 + # via sigstore +sigstore-rekor-types==0.0.18 \ + --hash=sha256:19aef25433218ebf9975a1e8b523cc84aaf3cd395ad39a30523b083ea7917ec5 \ + --hash=sha256:b62bf38c5b1a62bc0d7fe0ee51a0709e49311d137c7880c329882a8f4b2d1d78 + # via sigstore +tuf==7.0.0 \ + --hash=sha256:572bdbdc9ff4a82278a0d4773e6100863b9b33023f27575e84ca65b486dd0d79 \ + --hash=sha256:9d2e6723538e0d5a3e482b6de805fcfe64481448d5853039ba6b06ba541efd7f + # via sigstore +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # pydantic + # pydantic-core + # pyopenssl + # sigstore-models + # typing-inspection +typing-inspection==0.4.2 \ + --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ + --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 + # via pydantic +urllib3==2.7.0 \ + --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ + --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 + # via + # id + # requests + # tuf +uv==0.11.7 \ + --hash=sha256:0df59ab0c6a4b14a763e8445e1c303af9abeb53cdfa4428daf9ff9642c0a3cce \ + --hash=sha256:162fa961a9a081dcea6e889c79f738a5ae56507047e4672964972e33c301bea9 \ + --hash=sha256:23d457d6731ebdb83f1bffebe4894edab2ef43c1ec5488433c74300db4958924 \ + --hash=sha256:46d971489b00bdb27e0aa715e4a5cd4ef2c28ea5b6ef78f2b67bf861eb44b405 \ + --hash=sha256:4e4d5e31bea86e1b6e0f5a0f95e14e80018e6f6c0129256d2915a4b3d793644d \ + --hash=sha256:553e67cc766d013ce24353fecd4ea5533d2aedcfd35f9fac430e07b1d1f23ed4 \ + --hash=sha256:5674dfb5944513f4b3735b05c2deba6b1b01151f46729d533d413a9a905f8c5d \ + --hash=sha256:5985a15a92bd9a170fc1947abb1fbc3e9828c5a430ad85b5bed8356c20b67a71 \ + --hash=sha256:6158b7e39464f1aa1e040daa0186cae4749a78b5cd80ac769f32ca711b8976b1 \ + --hash=sha256:750ee5b96959b807cf442b73dd8b55111862d63f258f896787ea5f06b68aaca9 \ + --hash=sha256:7d6a17507b8139b8803f445a03fd097f732ce8356b1b7b13cdb4dd8ef7f4b2e0 \ + --hash=sha256:8b2fe1ec6775dad10183e3fdce430a5b37b7857d49763c884f3a67eaa8ca6f8a \ + --hash=sha256:ceae53b202ea92bc954759bc7c7570cdcd5c3512fce15701198c19fd2dfb8605 \ + --hash=sha256:dd48823ca4b505124389f49ae50626ba9f57212b9047738efc95126ed5f3844d \ + --hash=sha256:eb91f52ee67e10d5290f2c2897e2171357f1a10966de38d83eefa93d96843b0c \ + --hash=sha256:f394331f0507e80ee732cb3df737589de53bed999dd02a6d24682f08c2f8ac4f \ + --hash=sha256:f422d39530516b1dfb28bb6e90c32bb7dacd50f6a383cd6e40c1a859419fbc8c \ + --hash=sha256:f97e9f4e4d44fb5c4dfaa05e858ef3414a96416a2e4af270ecd88a3e5fb049a9 \ + --hash=sha256:fab0bb43fbbc0ee5b5fee212078d2300c371b725faff7cf72eeaafa0bff0606b + # via -r constraints/release-publish.in diff --git a/docs/distribution.md b/docs/distribution.md index 1d238011..997aa697 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -37,14 +37,17 @@ fails — is in [`release-runbook.md`](release-runbook.md). ### Protected qualification inputs -Every tag release fails closed unless all six variables below are configured. -They are read by the read-only verification job, which runs without an -environment so it can run unattended and produce the evidence the `pypi` -reviewers approve against — so the values must exist at **repository** scope. -Environment-scoped values still override them for the publication job. Values -must be populated by the independent benchmark-owner promotion flow after it -runs the frozen corpus against the exact wheel and signs -`safety-qualification.json`: +Qualification configuration is split by trust level. The values that +**authenticate** the evidence — the Sigstore signer identity and OIDC issuer — +live in reviewed code at `.github/release-trust-roots.json`, never in +variables: an actor able to set variables could otherwise substitute fabricated +qualification evidence *and* replace the identity that vouches for it, in one +step with no diff to review. + +Only the content-addressed **locations** are variables, at **repository** scope +so the unattended verification job can read them. Values must be populated by +the independent benchmark-owner promotion flow after it runs the frozen corpus +against the exact wheel and signs `safety-qualification.json`: | Variable | Required value | |---|---| @@ -52,8 +55,6 @@ runs the frozen corpus against the exact wheel and signs | `SAFETY_QUALIFICATION_WHEEL_FILENAME` | Safe wheel basename, for example `agents_shipgate-0.16.0b6-py3-none-any.whl` | | `SAFETY_QUALIFICATION_JSON_URL` | HTTPS URL for the production-qualified JSON artifact | | `SAFETY_QUALIFICATION_SIGSTORE_BUNDLE_URL` | HTTPS URL for that JSON artifact's Sigstore bundle | -| `SAFETY_QUALIFICATION_SIGNER_IDENTITY` | Exact trusted certificate identity configured for qualification promotion | -| `SAFETY_QUALIFICATION_OIDC_ISSUER` | Trusted OIDC issuer, normally `https://token.actions.githubusercontent.com` for GitHub Actions | The verification job checks the signature identity first, then validates the artifact's production policy, 100-case invariants, tag/version, and wheel diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 13711c56..8733966e 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -9,17 +9,39 @@ For the packaging surface and post-release fan-out checks, see ## The pipeline -A release runs as two jobs with an explicit, content-addressed handoff. - -| Job | Workflow | Permissions | What it does | -|---|---|---|---| -| `verify` | `release-verify.yml` (reusable) | `contents: read` | Builds from the tagged source, validates the signed qualification, binds wheel to source, runs the correctness suite, audits dependencies, produces the wheel-scoped SBOM, uploads a candidate bundle | -| `publish` | `release.yml` | `contents: write`, `id-token: write`, `environment: pypi` | Re-derives every digest, signs, drafts the GitHub Release, publishes to PyPI once, validates assets, finalises | - -Verification holds no write or OIDC authority, so the expensive read-only work -cannot mutate anything. The `pypi` environment's required-reviewer gate sits on -`publish` alone, which means reviewers approve **after** the readiness summary -exists rather than approving a run whose evidence has not been produced yet. +A release runs as four jobs with an explicit, content-addressed handoff. + +| Job | Permissions | What it does | +|---|---|---| +| `verify` | `contents: read` | Builds from the tagged source, validates the signed qualification, binds wheel to source, runs the correctness suite, audits dependencies, produces the wheel-scoped SBOM, uploads a candidate bundle | +| `stage` | `contents: write`, `actions: read` | Re-peels the tag, requires a matching rehearsal, re-derives every digest, classifies the index, creates or repairs the **draft** release | +| `publish` | `id-token: write`, `environment: pypi` | Signs and uploads to PyPI once | +| `finalize` | `contents: write` | Attaches signatures, confirms the index, validates assets, undrafts | + +The split is about which capabilities are ever held together. `publish` can +mint a PyPI Trusted Publishing token, so it holds **no repository write**, +checks out **no project code**, and installs only the hash-locked toolchain in +`constraints/release-publish.txt` with `--require-hashes`. Conversely `stage` +and `finalize` can write to the repository but cannot mint a token. A +dependency compromised in any single job therefore cannot reach both registries. + +Verification holds no write or OIDC authority at all, so the expensive +read-only work cannot mutate anything. The `pypi` environment's +required-reviewer gate sits on `publish` alone, so reviewers approve **after** +the readiness summary exists rather than approving a run whose evidence has not +been produced yet. + +### The candidate is pinned to a commit, not a tag + +`release.yml` passes `github.sha` — never `github.ref` — into verification. A +symbolic ref is re-resolved by the checkout action, so a tag moved between the +push event and the checkout would build one commit while provenance recorded +another. Every downstream binding is keyed to the SHA the verification job +actually resolved with `git rev-parse HEAD`. + +Because a tag can still move (or be deleted) *after* verification, both `stage` +and `publish` re-peel it against the remote immediately before acting, and the +draft is created with `gh release create --verify-tag`. ### Which artifact is authoritative @@ -63,14 +85,22 @@ opening an issue to track the gap, and it still rejects any content difference. ## Before tagging: rehearse -**A rehearsal on the candidate commit is a prerequisite for pushing a tag.** -Before this existed, the verification and failure paths of the release workflow -were first-run at the same moment publication became possible. +**A rehearsal on the candidate commit is a prerequisite for pushing a tag, and +the pipeline enforces it.** Before this existed, the verification and failure +paths of the release workflow were first-run at the same moment publication +became possible. Run the **Release Rehearsal** workflow (`workflow_dispatch`) against the candidate ref. It calls the same reusable verification workflow the release uses — same build, qualification validation, tests, audit, SBOM, and handoff. +`stage` refuses to proceed without a successful rehearsal run whose `head_sha` +equals the verified commit — which binds the workflow revision too, since both +live in the same tree — and whose candidate manifest is byte-identical to the +tagged one. That second check binds candidate *identity*: a qualification +artifact swapped between the rehearsal and the tag is caught even though the +source did not change. + It cannot publish, for three independent reasons: there is no publication job in the file, `permissions: contents: read` caps the token so tag and release creation fail, and no `id-token: write` anywhere means Trusted Publishing cannot @@ -83,13 +113,16 @@ Check the run's readiness summary before tagging: means the backend pin drifted); - the wheel SHA-256 matches the wheel you expect to ship. -### Rehearsing a failure path +### The failure path is rehearsed automatically + +Every rehearsal runs a fault-injection drill: it corrupts a *copy* of the +qualified wheel and asserts the provenance gate rejects it, failing the +rehearsal if the tampered artifact is accepted. The deliberate-mismatch +exercise is therefore executed on every run rather than left to operator +discipline, and the rejection message appears in the log. -At least once per release-process change, prove the gate fails closed. The -cheapest deliberate mismatch: point `SAFETY_QUALIFICATION_WHEEL_URL` at a wheel -from a different commit and confirm the rehearsal fails at **Bind the qualified -wheel to the tagged source tree**, naming the differing members. Restore the -variable afterwards. +The drill runs only in rehearsal mode — a real release must not spend its +budget on drills. ### Re-deriving the timeout @@ -137,10 +170,21 @@ This is the case the ordering is designed for. PyPI holds the version and a **draft** GitHub Release holds the wheel, SBOM, signatures, qualification artifacts, provenance record, and candidate manifest. -Re-run the `publish` job. It is idempotence-aware: +Re-run the workflow. It is idempotence-aware: `scripts/release_publication.py pypi-state` classifies the index as `published_identical`, the upload step is skipped via its `if:` condition, and -the job proceeds to asset validation and finalisation. +the run proceeds to asset validation and finalisation. + +`published_identical` is deliberately strict — it requires the index to hold +*exactly one* unyanked wheel with the expected filename and digest. A version +that also carries a divergent sdist, a second wheel, a renamed file, or a +yanked record is **not** treated as identical, because skipping the upload and +finalising over it would ship a release this pipeline never verified. + +A re-run also never mutates an already-published GitHub Release. It downloads +the published assets, proves they are the verified ones, and leaves them alone; +only drafts are repaired. Clobbering a published release's assets would replace +public bytes that immutable PyPI can no longer be made to match. If re-running is not possible, finalise by hand — the draft already has the authoritative assets: @@ -171,11 +215,33 @@ Nothing outside the run changed: no tag deletion, no cleanup needed. Fix the cause on the branch, and either move the tag (only safe while nothing has been published for it) or cut a new version. -## Required repository configuration +## Required configuration + +Qualification configuration is split by trust level: **what authenticates the +evidence** lives in reviewed code, and only **where the evidence lives** is +mutable. + +### Trust roots — reviewed code -The six qualification variables are read by the **verification** job, which -deliberately runs without an environment so it can run unattended. They must -therefore be available at **repository** scope: +`.github/release-trust-roots.json` holds the two values that authenticate the +signed qualification artifact: + +| Field | Value | +|---|---| +| `signer_identity` | Exact Sigstore certificate identity of the qualification promotion job | +| `oidc_issuer` | Trusted OIDC issuer, normally `https://token.actions.githubusercontent.com` | + +These must **not** be variables. An actor able to set variables could otherwise +substitute fabricated qualification evidence *and* replace the identity that +vouches for it, in a single step with no diff to review. Source-to-wheel +binding does not compensate: that attack reuses the legitimate wheel and forges +only the safety claims about it. + +Both ship as `CHANGE_ME` until the promotion flow exists. The release **fails +closed** while either is unset rather than defaulting to something permissive. +Changing either is a trust-root change and is reviewed as one. + +### Artifact locations — repository variables | Variable | Value | |---|---| @@ -183,15 +249,12 @@ therefore be available at **repository** scope: | `SAFETY_QUALIFICATION_WHEEL_FILENAME` | Safe wheel basename | | `SAFETY_QUALIFICATION_JSON_URL` | HTTPS URL for the qualified JSON artifact | | `SAFETY_QUALIFICATION_SIGSTORE_BUNDLE_URL` | HTTPS URL for that artifact's Sigstore bundle | -| `SAFETY_QUALIFICATION_SIGNER_IDENTITY` | Trusted certificate identity for qualification promotion | -| `SAFETY_QUALIFICATION_OIDC_ISSUER` | Trusted OIDC issuer | - -These are variables, not secrets — they are URLs and identities, readable by any -workflow in the repository regardless of scope. Environment-scoped values still -override repository ones for the `publish` job. - -Moving them to repository scope does weaken *who can change them* relative to -environment-scoped variables. That is an accepted trade, because the control -that mattered is now stronger: a tampered wheel URL no longer reaches PyPI, it -fails the source-binding gate. Variable ACLs were doing work that -content-addressing now does directly. + +These are read by the verification job, which deliberately runs without an +environment so it can run unattended, so they live at **repository** scope. +Leaving them mutable is safe precisely because they are only *locations*: +pointing one somewhere else fails either the signature check against the +committed trust root or the source-to-wheel provenance gate. + +None of the four are currently set. Until they are, the release stops at +**Require configured qualification artifact locations**. diff --git a/scripts/_release_support.py b/scripts/_release_support.py new file mode 100644 index 00000000..715b0256 --- /dev/null +++ b/scripts/_release_support.py @@ -0,0 +1,81 @@ +"""Standard-library-only helpers for the publication side of a release. + +Everything here is deliberately import-free beyond the standard library. + +The jobs that hold `id-token: write` can mint a PyPI Trusted Publishing token, +so any code they execute is inside the blast radius of a compromised +dependency. Installing the editable project plus its ranged dev extras into +such a job would put a build backend and a dozen transitive packages in that +position — before the handoff has even been verified. + +Keeping the publication-side scripts on the standard library means those jobs +install nothing but the single pinned tool they need (`uv` to upload, +`sigstore` to sign), and never execute project code at all. + +The read-only verification job has no such constraint and may use the full +project; `scripts/verify_wheel_provenance.py` runs only there. +""" + +from __future__ import annotations + +import hashlib +import re +import zipfile +from email.parser import BytesParser +from email.policy import default as email_policy +from pathlib import Path + +DISTRIBUTION_NAME = "agents-shipgate" +SHA256_PATTERN = re.compile(r"\A[0-9a-f]{64}\Z") + + +class ReleaseError(RuntimeError): + """A release precondition failed and publication must not proceed.""" + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def canonicalize_name(name: str) -> str: + """Canonical distribution name per PEP 503. + + Reimplemented rather than imported from ``packaging`` so this module stays + dependency-free; the rule is one regex and is stable. + """ + + return re.sub(r"[-_.]+", "-", name).lower() + + +def inspect_wheel(path: Path) -> tuple[str, str, str]: + """Return canonical distribution name, version, and content digest.""" + + if not path.is_file() or path.suffix != ".whl": + raise ReleaseError(f"Wheel not found or not a .whl file: {path}") + try: + with zipfile.ZipFile(path) as archive: + metadata_names = [ + name + for name in archive.namelist() + if name.endswith(".dist-info/METADATA") and "/" in name + ] + if len(metadata_names) != 1: + raise ReleaseError( + f"Wheel must contain exactly one .dist-info/METADATA file: {path}" + ) + metadata = BytesParser(policy=email_policy).parsebytes(archive.read(metadata_names[0])) + except (OSError, zipfile.BadZipFile, KeyError) as exc: + raise ReleaseError(f"Invalid wheel {path}: {exc}") from exc + + name = str(metadata.get("Name", "")).strip() + version = str(metadata.get("Version", "")).strip() + canonical_name = canonicalize_name(name) + if canonical_name != DISTRIBUTION_NAME or not version: + raise ReleaseError( + f"Release requires an {DISTRIBUTION_NAME} wheel with Name and Version: {path}" + ) + return canonical_name, version, sha256_file(path) diff --git a/scripts/release_publication.py b/scripts/release_publication.py index 2fa0dc35..281b12d3 100644 --- a/scripts/release_publication.py +++ b/scripts/release_publication.py @@ -1,5 +1,9 @@ #!/usr/bin/env python3 -"""Content-addressed handoff and idempotence guard for the publication job. +"""Content-addressed handoff and idempotence guard for the publication jobs. + +Standard library only, on purpose — see ``scripts/_release_support``. The jobs +that run this are the ones able to mint a PyPI token, so they install no +project code. Verification and publication run as separate jobs so that expensive, read-only checking cannot hold write or OIDC permissions, and so an immutable PyPI upload @@ -15,7 +19,10 @@ job output digest -> manifest bytes -> per-asset digests -> asset bytes -Any substitution in the artifact store breaks one of those links. +The check is closed-world. Verifying only the *listed* assets would leave an +intact manifest sitting beside an unlisted sdist or executable that a +subsequent ``dist/*`` upload would happily publish, so the directory contents +must equal the manifest exactly, and every entry must be a regular file. ``pypi-state`` answers the question a retry must ask before re-uploading an immutable version. PyPI uploads cannot be replaced, so "just re-run the job" is @@ -25,17 +32,23 @@ ``absent`` Version not on the index. Publication proceeds. ``published_identical`` - Version exists and the index holds this exact wheel digest. The upload - already succeeded; a re-run is completing an interrupted transaction, so - the publish step is skipped and finalization continues. + The index holds exactly one file for this version: an unyanked wheel with + the expected filename and digest. The upload already succeeded; a re-run is + completing an interrupted transaction, so the publish step is skipped and + finalisation continues. ``published_divergent`` - Version exists with *different* bytes. Always fatal: the tag would ship - something other than what is on the index. + Anything else. Always fatal. + +That last classification is deliberately strict about *the whole file set*, not +just "our digest appears somewhere". A release that also carries a divergent +sdist, a second wheel, a renamed file, or a yanked record is not the release +this pipeline verified, and treating it as identical would skip the upload and +finalise over it. Run from the repo root: python scripts/release_publication.py manifest --tag v0.16.0 \\ - --source-commit "$GITHUB_SHA" --wheel dist/agents_shipgate-0.16.0-py3-none-any.whl \\ + --source-commit "$SOURCE_SHA" --wheel dist/agents_shipgate-0.16.0-py3-none-any.whl \\ --asset dist/agents-shipgate-sbom.json --output dist/candidate-manifest.json python scripts/release_publication.py verify-manifest \\ --manifest dist/candidate-manifest.json --expected-sha256 "$DIGEST" @@ -46,7 +59,6 @@ from __future__ import annotations import argparse -import hashlib import json import sys import urllib.error @@ -54,12 +66,20 @@ from pathlib import Path from typing import Any -from agents_shipgate.core.errors import ConfigError - if __package__: - from scripts.run_safety_qualification import inspect_wheel, sha256_file + from scripts._release_support import ( + SHA256_PATTERN, + ReleaseError, + inspect_wheel, + sha256_file, + ) else: # ``python scripts/release_publication.py`` - from run_safety_qualification import inspect_wheel, sha256_file + from _release_support import ( + SHA256_PATTERN, + ReleaseError, + inspect_wheel, + sha256_file, + ) DEFAULT_INDEX = "https://pypi.org/pypi" _NETWORK_TIMEOUT_SECONDS = 30 @@ -77,12 +97,12 @@ def build_manifest( wheel_name, wheel_version, wheel_sha256 = inspect_wheel(wheel_path) if tag != f"v{wheel_version}": - raise ConfigError(f"Release tag {tag} does not match wheel version {wheel_version}") + raise ReleaseError(f"Release tag {tag} does not match wheel version {wheel_version}") assets = [] for path in sorted({*asset_paths, wheel_path}, key=lambda item: item.name): if not path.is_file(): - raise ConfigError(f"Candidate asset not found: {path}") + raise ReleaseError(f"Candidate asset not found: {path}") assets.append({"filename": path.name, "sha256": sha256_file(path)}) manifest = { @@ -99,47 +119,99 @@ def build_manifest( return manifest +def _assert_closed_world( + manifest_path: Path, base: Path, expected: set[str], allowed_extra: set[str] +) -> None: + """Reject anything in the candidate directory the manifest does not name. + + ``allowed_extra`` is an explicit allowlist, not an escape hatch: the only + legitimate additions are the signature bundles produced *after* the + manifest is sealed, and naming them individually keeps the check + closed-world. + """ + + allowed = expected | {manifest_path.name} | allowed_extra + present: set[str] = set() + for entry in sorted(base.rglob("*")): + if entry.is_dir(): + continue + relative = entry.relative_to(base).as_posix() + if entry.is_symlink() or not entry.is_file(): + raise ReleaseError(f"Candidate handoff contains a non-regular entry: {relative}") + present.add(relative) + + unexpected = sorted(present - allowed) + if unexpected: + raise ReleaseError( + "Candidate handoff contains files the manifest does not list " + f"({', '.join(unexpected)}); publication would upload unverified bytes." + ) + + def verify_manifest( - *, manifest_path: Path, expected_sha256: str | None = None, directory: Path | None = None + *, + manifest_path: Path, + expected_sha256: str | None = None, + directory: Path | None = None, + allowed_extra: set[str] | None = None, ) -> dict[str, Any]: - """Re-derive every digest the verification job recorded.""" + """Re-derive every digest the verification job recorded. + + ``expected_sha256`` is compared whenever it is supplied, including when it + is an empty or malformed string. A truthiness test here would fail open: + a missing or redacted job output arrives as ``""`` and the workflow still + passes ``--expected-sha256 ""``, silently disabling the one binding that + does not travel through the artifact store. + """ if not manifest_path.is_file(): - raise ConfigError(f"Candidate manifest not found: {manifest_path}") - actual_sha256 = sha256_file(manifest_path) - if expected_sha256 and actual_sha256 != expected_sha256: - raise ConfigError( - "Candidate manifest digest does not match the verification job output " - f"(expected {expected_sha256}, got {actual_sha256}); the artifact handoff was " - "modified between verification and publication." - ) + raise ReleaseError(f"Candidate manifest not found: {manifest_path}") + + if expected_sha256 is not None: + if not SHA256_PATTERN.fullmatch(expected_sha256): + raise ReleaseError( + "Expected manifest digest is not a 64-character lowercase SHA-256 " + f"({expected_sha256!r}); the verification job output was missing or redacted." + ) + actual_sha256 = sha256_file(manifest_path) + if actual_sha256 != expected_sha256: + raise ReleaseError( + "Candidate manifest digest does not match the verification job output " + f"(expected {expected_sha256}, got {actual_sha256}); the artifact handoff was " + "modified between verification and publication." + ) + try: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: - raise ConfigError(f"Invalid candidate manifest {manifest_path}: {exc}") from exc + raise ReleaseError(f"Invalid candidate manifest {manifest_path}: {exc}") from exc base = directory or manifest_path.parent errors: list[str] = [] + listed: set[str] = set() for asset in manifest.get("assets", []): - path = base / str(asset.get("filename", "")) + filename = str(asset.get("filename", "")) + listed.add(filename) + path = base / filename if not path.is_file(): - errors.append(f"missing asset {asset.get('filename')}") + errors.append(f"missing asset {filename}") continue digest = sha256_file(path) if digest != asset.get("sha256"): errors.append( - f"{asset.get('filename')} digest {digest} does not match " - f"the verified {asset.get('sha256')}" + f"{filename} digest {digest} does not match the verified {asset.get('sha256')}" ) if errors: - raise ConfigError("Candidate handoff rejected: " + "; ".join(errors)) + raise ReleaseError("Candidate handoff rejected: " + "; ".join(errors)) + + _assert_closed_world(manifest_path, base, listed, allowed_extra or set()) return manifest def _fetch_release_files(distribution: str, version: str, index: str) -> list[dict[str, Any]]: url = f"{index.rstrip('/')}/{distribution}/{version}/json" if not url.startswith("https://"): - raise ConfigError(f"Index URL must use HTTPS: {url}") + raise ReleaseError(f"Index URL must use HTTPS: {url}") request = urllib.request.Request(url, headers={"Accept": "application/json"}) try: with urllib.request.urlopen( # noqa: S310 - scheme asserted https above @@ -149,30 +221,57 @@ def _fetch_release_files(distribution: str, version: str, index: str) -> list[di except urllib.error.HTTPError as exc: if exc.code == 404: return [] - raise ConfigError(f"Unable to query {url}: HTTP {exc.code}") from exc + raise ReleaseError(f"Unable to query {url}: HTTP {exc.code}") from exc except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, UnicodeError) as exc: # Deliberately not treated as "absent": an unreachable index must not # be read as permission to upload. - raise ConfigError(f"Unable to query {url}: {exc}") from exc - return list(payload.get("urls", [])) + raise ReleaseError(f"Unable to query {url}: {exc}") from exc + if not isinstance(payload, dict): + raise ReleaseError(f"Malformed index response for {url}") + files = payload.get("urls", []) + if not isinstance(files, list) or not all(isinstance(item, dict) for item in files): + raise ReleaseError(f"Malformed index file list for {url}") + return files + + +def _classify_published( + files: list[dict[str, Any]], *, wheel_filename: str, wheel_sha256: str +) -> str: + """Require the index to hold exactly the one file this pipeline publishes.""" + + if len(files) != 1: + return "published_divergent" + record = files[0] + digests = record.get("digests") + if not isinstance(digests, dict): + return "published_divergent" + matches = ( + str(record.get("filename", "")) == wheel_filename + and str(record.get("packagetype", "")) == "bdist_wheel" + and str(digests.get("sha256", "")) == wheel_sha256 + and record.get("yanked") is not True + ) + return "published_identical" if matches else "published_divergent" def pypi_state(*, wheel_path: Path, index: str = DEFAULT_INDEX) -> dict[str, Any]: - """Classify whether this exact wheel is already on the index.""" + """Classify whether this exact wheel — and nothing else — is on the index.""" distribution, version, wheel_sha256 = inspect_wheel(wheel_path) files = _fetch_release_files(distribution, version, index) if not files: state = "absent" else: - digests = {str(item.get("digests", {}).get("sha256", "")) for item in files} - state = "published_identical" if wheel_sha256 in digests else "published_divergent" + state = _classify_published( + files, wheel_filename=wheel_path.name, wheel_sha256=wheel_sha256 + ) if state == "published_divergent": - raise ConfigError( - f"{distribution} {version} is already on the index with different bytes. " - "PyPI uploads are immutable, so this tag cannot be republished. Cut a new " - "version; see docs/release-runbook.md for the recovery procedure." + raise ReleaseError( + f"{distribution} {version} is already on the index, but not as the single " + f"unyanked wheel {wheel_path.name} with digest {wheel_sha256}. PyPI uploads are " + "immutable, so this tag cannot be republished. Cut a new version; see " + "docs/release-runbook.md for the recovery procedure." ) return { "state": state, @@ -206,8 +305,25 @@ def _parser() -> argparse.ArgumentParser: verify = subparsers.add_parser("verify-manifest", help="re-derive the handoff digests") verify.add_argument("--manifest", type=Path, required=True) - verify.add_argument("--expected-sha256") + verify.add_argument( + "--expected-sha256", + required=True, + help=( + "manifest digest from the verification job output; required so a missing " + "or redacted value cannot silently skip the binding" + ), + ) verify.add_argument("--directory", type=Path) + verify.add_argument( + "--allow", + action="append", + default=[], + metavar="FILENAME", + help=( + "additionally permit this filename in the directory; for signature " + "bundles produced after the manifest was sealed" + ), + ) state = subparsers.add_parser("pypi-state", help="classify the index state for this wheel") state.add_argument("--wheel", type=Path, required=True) @@ -228,7 +344,7 @@ def main(argv: list[str] | None = None) -> int: asset_paths=list(args.asset), output_path=args.output, ) - digest = hashlib.sha256(args.output.read_bytes()).hexdigest() + digest = sha256_file(args.output) sys.stdout.write( f"OK: candidate manifest for {manifest['release_tag']} lists " f"{len(manifest['assets'])} assets; manifest sha256 {digest}.\n" @@ -238,6 +354,7 @@ def main(argv: list[str] | None = None) -> int: manifest_path=args.manifest, expected_sha256=args.expected_sha256, directory=args.directory, + allowed_extra=set(args.allow), ) sys.stdout.write( f"OK: all {len(manifest['assets'])} candidate assets match the verified digests.\n" @@ -255,7 +372,7 @@ def main(argv: list[str] | None = None) -> int: f"OK: {result['distribution']} {result['version']} index state " f"is {result['state']}; should_publish={result['should_publish']}.\n" ) - except (ConfigError, OSError, ValueError) as exc: + except (ReleaseError, OSError, ValueError) as exc: sys.stderr.write(f"Release publication error: {exc}\n") return 1 return 0 diff --git a/scripts/release_sbom.py b/scripts/release_sbom.py index c37522b6..c453ef03 100644 --- a/scripts/release_sbom.py +++ b/scripts/release_sbom.py @@ -39,6 +39,7 @@ import argparse import json import os +import re import subprocess import sys import tempfile @@ -47,15 +48,12 @@ from pathlib import Path from typing import Any -from packaging.requirements import Requirement -from packaging.utils import canonicalize_name - -from agents_shipgate.core.errors import ConfigError - if __package__: - from scripts.run_safety_qualification import inspect_wheel + from scripts._release_support import ReleaseError as ConfigError + from scripts._release_support import canonicalize_name, inspect_wheel else: # ``python scripts/release_sbom.py`` - from run_safety_qualification import inspect_wheel + from _release_support import ReleaseError as ConfigError + from _release_support import canonicalize_name, inspect_wheel REPO_ROOT = Path(__file__).resolve().parent.parent WHEEL_FILENAME_PROPERTY = "agents-shipgate:wheel-filename" @@ -79,11 +77,25 @@ def dev_only_distributions(pyproject_path: Path) -> set[str]: runtime = project.get("dependencies", []) if not dev: raise ConfigError(f"{pyproject_path} declares no [project.optional-dependencies].dev") - dev_names = {canonicalize_name(Requirement(item).name) for item in dev} - runtime_names = {canonicalize_name(Requirement(item).name) for item in runtime} + dev_names = {_requirement_name(item) for item in dev} + runtime_names = {_requirement_name(item) for item in runtime} return dev_names - runtime_names +def _requirement_name(requirement: str) -> str: + """Canonical distribution name from a PEP 508 requirement string. + + Only the leading name is needed, so this avoids a ``packaging`` import and + keeps the module usable from the publication jobs, which install no + project dependencies. + """ + + match = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)", requirement) + if not match: + raise ConfigError(f"Unparsable requirement string: {requirement!r}") + return canonicalize_name(match.group(1)) + + def _component_names(document: dict[str, Any]) -> set[str]: return { canonicalize_name(str(component.get("name", ""))) @@ -112,6 +124,56 @@ def _assert_runtime_only(document: dict[str, Any], *, sbom_path: Path) -> None: ) +def _promote_subject_component( + document: dict[str, Any], + *, + wheel_name: str, + wheel_version: str, + wheel_sha256: str, + wheel_filename: str, +) -> None: + """Move CycloneDX's own component for the wheel into ``metadata.component``. + + Inventing a fresh ``bom-ref`` here would leave the document describing the + subject twice: once as the invented metadata component and once as the + installed package in ``components``, with the dependency graph still keyed + by the original ref — so the declared subject would have no dependency node + at all. Reusing the emitted component keeps the graph intact and leaves + exactly one node for the subject. + """ + + components = document.get("components", []) + subject = next( + ( + component + for component in components + if canonicalize_name(str(component.get("name", ""))) == wheel_name + and str(component.get("version", "")) == wheel_version + ), + None, + ) + if subject is None: + raise ConfigError( + f"SBOM does not inventory {wheel_name} {wheel_version}; the isolated " + "environment did not contain the wheel under test." + ) + + components.remove(subject) + hashes = [entry for entry in subject.get("hashes", []) or [] if isinstance(entry, dict)] + if not any( + str(entry.get("alg", "")).upper() == "SHA-256" + and str(entry.get("content", "")) == wheel_sha256 + for entry in hashes + ): + hashes.append({"alg": "SHA-256", "content": wheel_sha256}) + subject["hashes"] = hashes + properties = [entry for entry in subject.get("properties", []) or [] if isinstance(entry, dict)] + properties.append({"name": WHEEL_FILENAME_PROPERTY, "value": wheel_filename}) + subject["properties"] = properties + + document.setdefault("metadata", {})["component"] = subject + + def build_release_sbom(*, wheel_path: Path, output_path: Path) -> dict[str, Any]: """Inventory an isolated runtime-only installation of ``wheel_path``.""" @@ -173,15 +235,13 @@ def build_release_sbom(*, wheel_path: Path, output_path: Path) -> dict[str, Any] document = json.loads(raw_path.read_text(encoding="utf-8")) _normalise_external_references(document, wheel_path.name) - metadata = document.setdefault("metadata", {}) - metadata["component"] = { - "type": "library", - "bom-ref": f"{wheel_name}-wheel", - "name": wheel_name, - "version": wheel_version, - "hashes": [{"alg": "SHA-256", "content": wheel_sha256}], - "properties": [{"name": WHEEL_FILENAME_PROPERTY, "value": wheel_path.name}], - } + _promote_subject_component( + document, + wheel_name=wheel_name, + wheel_version=wheel_version, + wheel_sha256=wheel_sha256, + wheel_filename=wheel_path.name, + ) _assert_runtime_only(document, sbom_path=output_path) output_path.parent.mkdir(parents=True, exist_ok=True) @@ -225,10 +285,48 @@ def verify_release_sbom(*, wheel_path: Path, sbom_path: Path) -> dict[str, Any]: f"SBOM {sbom_path} is not bound to {wheel_path.name}: " + "; ".join(errors) ) + _assert_subject_is_singular(document, sbom_path=sbom_path, wheel_name=wheel_name) _assert_runtime_only(document, sbom_path=sbom_path) return document +def _assert_subject_is_singular( + document: dict[str, Any], *, sbom_path: Path, wheel_name: str +) -> None: + """The subject appears once, and the dependency graph still refers to it. + + Guards the failure mode of describing the wheel both as the metadata + subject and as an ordinary installed package, which leaves consumers + unable to tell which node the document is actually about. + """ + + duplicates = sorted( + str(component.get("bom-ref", component.get("name"))) + for component in document.get("components", []) + if canonicalize_name(str(component.get("name", ""))) == wheel_name + ) + if duplicates: + raise ConfigError( + f"SBOM {sbom_path} lists {wheel_name} in components as well as " + f"metadata.component ({', '.join(duplicates)}); the subject is described twice." + ) + + subject_ref = str(document["metadata"]["component"].get("bom-ref", "")) + dependencies = document.get("dependencies") + if dependencies is None: + # cyclonedx-py emits a graph, but an SBOM without one is still bound by + # the digest checks above; only assert consistency when it is present. + return + matching = [ + node for node in dependencies if str(node.get("ref", "")) == subject_ref and subject_ref + ] + if len(matching) != 1: + raise ConfigError( + f"SBOM {sbom_path} has {len(matching)} dependency nodes for the declared " + f"subject {subject_ref!r}; expected exactly one." + ) + + def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Build or verify an SBOM scoped to the published wheel." diff --git a/scripts/verify_wheel_provenance.py b/scripts/verify_wheel_provenance.py index f0b324ce..4f8b85d1 100644 --- a/scripts/verify_wheel_provenance.py +++ b/scripts/verify_wheel_provenance.py @@ -51,6 +51,8 @@ from pathlib import Path from typing import Literal +from packaging.utils import InvalidWheelFilename, parse_wheel_filename + from agents_shipgate.core.errors import ConfigError ProvenanceMode = Literal["identical_bytes", "identical_payload", "mismatch"] @@ -122,9 +124,48 @@ def _describe_differences( return differences +def _assert_compatible_filenames(built_path: Path, qualified_path: Path) -> None: + """Require both basenames to describe the same distribution and platform. + + Checked before any byte comparison, because identical bytes under a + different name are not the same artifact to an installer: the filename's + version and compatibility tags are what pip matches against, so a wheel + renamed from ``py3-none-any`` to ``py2-none-any`` resolves differently + while hashing identically. + """ + + try: + built = parse_wheel_filename(built_path.name) + qualified = parse_wheel_filename(qualified_path.name) + except InvalidWheelFilename as exc: + raise ConfigError(f"Unparsable wheel filename: {exc}") from exc + + built_name, built_version, built_build, built_tags = built + qualified_name, qualified_version, qualified_build, qualified_tags = qualified + + differences: list[str] = [] + if built_name != qualified_name: + differences.append(f"distribution {built_name} vs {qualified_name}") + if built_version != qualified_version: + differences.append(f"version {built_version} vs {qualified_version}") + if built_build != qualified_build: + differences.append(f"build tag {built_build} vs {qualified_build}") + if built_tags != qualified_tags: + differences.append( + f"compatibility tags {sorted(map(str, built_tags))} vs " + f"{sorted(map(str, qualified_tags))}" + ) + if differences: + raise ConfigError( + "Built and qualified wheel filenames describe different artifacts: " + + "; ".join(differences) + ) + + def compare_wheels(built_path: Path, qualified_path: Path) -> tuple[ProvenanceMode, list[str]]: """Classify how a wheel built from source relates to the qualified wheel.""" + _assert_compatible_filenames(built_path, qualified_path) built_sha256 = _sha256_file(built_path) qualified_sha256 = _sha256_file(qualified_path) if built_sha256 == qualified_sha256: diff --git a/tests/test_release_pipeline.py b/tests/test_release_pipeline.py index 6155787b..73099a79 100644 --- a/tests/test_release_pipeline.py +++ b/tests/test_release_pipeline.py @@ -15,6 +15,7 @@ from __future__ import annotations +import hashlib import json import zipfile from pathlib import Path @@ -24,6 +25,7 @@ import yaml from agents_shipgate.core.errors import ConfigError +from scripts._release_support import ReleaseError from scripts.release_publication import pypi_state, verify_manifest from scripts.release_sbom import dev_only_distributions, verify_release_sbom from scripts.verify_wheel_provenance import compare_wheels, verify_wheel_provenance @@ -31,6 +33,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent WORKFLOWS = REPO_ROOT / ".github/workflows" +WHEEL_FILENAME = "agents_shipgate-9.9.9-py3-none-any.whl" WHEEL_METADATA = "Metadata-Version: 2.4\nName: agents-shipgate\nVersion: 9.9.9\n" DIST_INFO = "agents_shipgate-9.9.9.dist-info" @@ -40,12 +43,27 @@ def _write_wheel(path: Path, members: dict[str, str] | None = None, **kwargs: An payload = {f"{DIST_INFO}/METADATA": WHEEL_METADATA, "agents_shipgate/__init__.py": "x = 1\n"} payload.update(members or {}) + path.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(path, "w", **kwargs) as archive: for name, content in payload.items(): archive.writestr(name, content) return path +def _wheel_pair( + tmp_path: Path, qualified_members: dict[str, str] | None = None, **kwargs: Any +) -> tuple[Path, Path]: + """Built and qualified wheels sharing one basename, in separate directories.""" + + built = _write_wheel(tmp_path / "built" / WHEEL_FILENAME) + qualified = _write_wheel(tmp_path / "qualified" / WHEEL_FILENAME, qualified_members, **kwargs) + return built, qualified + + +def _digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + def _load_workflow(name: str) -> dict[str, Any]: document = yaml.safe_load((WORKFLOWS / name).read_text(encoding="utf-8")) # PyYAML resolves the bare key `on` to the boolean True (YAML 1.1), so @@ -62,14 +80,17 @@ def _step_index(job: dict[str, Any], needle: str) -> int: raise AssertionError(f"no step matching {needle!r}") +def _job_commands(job: dict[str, Any]) -> str: + return "\n".join(step["run"] for step in (job.get("steps") or []) if "run" in step) + + # -------------------------------------------------------------------------- # #342 — the published wheel comes from the tagged source # -------------------------------------------------------------------------- def test_identical_wheels_are_bound_by_bytes(tmp_path: Path) -> None: - built = _write_wheel(tmp_path / "built.whl") - qualified = _write_wheel(tmp_path / "qualified.whl") + built, qualified = _wheel_pair(tmp_path) record = verify_wheel_provenance(built_path=built, qualified_path=qualified) @@ -81,10 +102,7 @@ def test_qualified_wheel_carrying_extra_code_fails_closed(tmp_path: Path) -> Non """The attack #342 describes: a wheel with the right Name and Version but contents no tagged commit produced.""" - built = _write_wheel(tmp_path / "built.whl") - qualified = _write_wheel( - tmp_path / "qualified.whl", {"agents_shipgate/_backdoor.py": "import os\n"} - ) + built, qualified = _wheel_pair(tmp_path, {"agents_shipgate/_backdoor.py": "import os\n"}) with pytest.raises(ConfigError) as excinfo: verify_wheel_provenance(built_path=built, qualified_path=qualified) @@ -98,23 +116,20 @@ def test_qualified_wheel_carrying_extra_code_fails_closed(tmp_path: Path) -> Non def test_modified_module_contents_fail_closed(tmp_path: Path) -> None: - built = _write_wheel(tmp_path / "built.whl") - qualified = _write_wheel( - tmp_path / "qualified.whl", {"agents_shipgate/__init__.py": "x = 666\n"} - ) + built, qualified = _wheel_pair(tmp_path, {"agents_shipgate/__init__.py": "x = 666\n"}) - with pytest.raises(ConfigError) as excinfo: + with pytest.raises(ConfigError, match="content differs"): verify_wheel_provenance(built_path=built, qualified_path=qualified) - assert "content differs" in str(excinfo.value) - def test_container_only_difference_is_rejected_unless_explicitly_allowed(tmp_path: Path) -> None: """A repacked archive with identical payload is a reproducibility gap, not a source mismatch — but the weaker bar must be opted into, never inferred.""" - built = _write_wheel(tmp_path / "built.whl", compression=zipfile.ZIP_STORED) - qualified = _write_wheel(tmp_path / "qualified.whl", compression=zipfile.ZIP_DEFLATED) + built = _write_wheel(tmp_path / "built" / WHEEL_FILENAME, compression=zipfile.ZIP_STORED) + qualified = _write_wheel( + tmp_path / "qualified" / WHEEL_FILENAME, compression=zipfile.ZIP_DEFLATED + ) mode, differences = compare_wheels(built, qualified) assert mode == "identical_payload" @@ -130,6 +145,27 @@ def test_container_only_difference_is_rejected_unless_explicitly_allowed(tmp_pat assert record["byte_reproducible"] is False +@pytest.mark.parametrize( + ("renamed", "expected"), + [ + ("agents_shipgate-9.9.9-py2-none-any.whl", "compatibility tags"), + ("agents_shipgate-9.9.10-py3-none-any.whl", "version"), + ("other_dist-9.9.9-py3-none-any.whl", "distribution"), + ], +) +def test_identical_bytes_under_a_different_filename_are_rejected( + tmp_path: Path, renamed: str, expected: str +) -> None: + """Filename version and compatibility tags are what an installer resolves + against, so identical bytes under another name are a different artifact.""" + + built = _write_wheel(tmp_path / "built" / WHEEL_FILENAME) + qualified = _write_wheel(tmp_path / "qualified" / renamed) + + with pytest.raises(ConfigError, match=expected): + verify_wheel_provenance(built_path=built, qualified_path=qualified) + + def test_release_verification_gates_publication_on_source_binding() -> None: verify = _load_workflow("release-verify.yml")["jobs"]["verify"] @@ -152,33 +188,78 @@ def test_build_backend_is_pinned_so_byte_equality_is_achievable() -> None: assert build_step["env"]["PIP_CONSTRAINT"] == "constraints/release-build.txt" +# -------------------------------------------------------------------------- +# #342 / review — the candidate is pinned to an immutable commit +# -------------------------------------------------------------------------- + + +def test_verification_runs_against_an_immutable_commit_not_a_symbolic_ref() -> None: + """`github.ref` is re-resolved by the checkout action, so a tag moved + between the push event and the checkout would build a different commit than + the one provenance records.""" + + verify = _load_workflow("release.yml")["jobs"]["verify"] + + assert verify["with"]["ref"] == "${{ github.sha }}" + assert "github.ref " not in json.dumps(verify["with"]) + + +def test_provenance_is_keyed_to_the_resolved_checkout_sha() -> None: + verify = _load_workflow("release-verify.yml")["jobs"]["verify"] + commands = _job_commands(verify) + + # Resolved after checkout rather than taken from the event. + assert 'source_sha="$(git rev-parse HEAD)"' in commands + assert '--source-commit "${SOURCE_SHA}"' in commands + assert verify["outputs"]["source_sha"] == "${{ steps.candidate.outputs.source_sha }}" + + +def test_tag_is_reconfirmed_before_each_irreversible_step() -> None: + """A tag can move or be deleted after verification; both the staging job + and the token-bearing publish job re-peel it before acting.""" + + release = _load_workflow("release.yml") + + for job_name in ("stage", "publish"): + commands = _job_commands(release["jobs"][job_name]) + assert "git ls-remote" in commands, job_name + assert "refs/tags/${RELEASE_TAG}^{}" in commands, job_name + assert "not the verified ${SOURCE_SHA}" in commands, job_name + + # gh refuses to create a release for a tag that is not on the remote. + assert "--verify-tag" in _job_commands(release["jobs"]["stage"]) + + # -------------------------------------------------------------------------- # #356 — the SBOM describes the shipped wheel # -------------------------------------------------------------------------- -def _sbom(components: list[str], *, digest: str, version: str = "9.9.9") -> dict[str, Any]: +def _sbom( + components: list[str], + *, + digest: str, + version: str = "9.9.9", + subject_ref: str = "agents-shipgate==9.9.9", + dependencies: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: return { "bomFormat": "CycloneDX", "specVersion": "1.6", "metadata": { "component": { "type": "library", + "bom-ref": subject_ref, "name": "agents-shipgate", "version": version, "hashes": [{"alg": "SHA-256", "content": digest}], } }, "components": [{"name": name, "version": "1.0"} for name in components], + **({"dependencies": dependencies} if dependencies is not None else {}), } -def _digest(path: Path) -> str: - import hashlib - - return hashlib.sha256(path.read_bytes()).hexdigest() - - def test_dev_only_guard_covers_the_release_tooling_that_leaked_before() -> None: forbidden = dev_only_distributions(REPO_ROOT / "pyproject.toml") @@ -189,86 +270,160 @@ def test_dev_only_guard_covers_the_release_tooling_that_leaked_before() -> None: def test_sbom_containing_a_dev_only_dependency_is_rejected(tmp_path: Path) -> None: - wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") + wheel = _write_wheel(tmp_path / WHEEL_FILENAME) sbom_path = tmp_path / "sbom.json" sbom_path.write_text( json.dumps(_sbom(["pydantic", "pytest"], digest=_digest(wheel))), encoding="utf-8" ) - with pytest.raises(ConfigError, match="dev-only"): + with pytest.raises(ReleaseError, match="dev-only"): verify_release_sbom(wheel_path=wheel, sbom_path=sbom_path) def test_sbom_for_a_runtime_only_environment_is_accepted(tmp_path: Path) -> None: - wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") + wheel = _write_wheel(tmp_path / WHEEL_FILENAME) sbom_path = tmp_path / "sbom.json" sbom_path.write_text( - json.dumps(_sbom(["agents-shipgate", "pydantic", "typer"], digest=_digest(wheel))), - encoding="utf-8", + json.dumps(_sbom(["pydantic", "typer"], digest=_digest(wheel))), encoding="utf-8" ) assert verify_release_sbom(wheel_path=wheel, sbom_path=sbom_path) def test_sbom_describing_different_bytes_fails_before_publication(tmp_path: Path) -> None: - wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") - other = _write_wheel(tmp_path / "other.whl", {"agents_shipgate/_extra.py": "y = 2\n"}) + wheel = _write_wheel(tmp_path / WHEEL_FILENAME) + other = _write_wheel(tmp_path / "other" / WHEEL_FILENAME, {"agents_shipgate/_x.py": "y = 2\n"}) sbom_path = tmp_path / "sbom.json" sbom_path.write_text(json.dumps(_sbom(["pydantic"], digest=_digest(other))), encoding="utf-8") - with pytest.raises(ConfigError, match="no SHA-256 matching the wheel"): + with pytest.raises(ReleaseError, match="no SHA-256 matching the wheel"): verify_release_sbom(wheel_path=wheel, sbom_path=sbom_path) def test_sbom_without_a_bound_component_is_rejected(tmp_path: Path) -> None: - wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") + wheel = _write_wheel(tmp_path / WHEEL_FILENAME) sbom_path = tmp_path / "sbom.json" sbom_path.write_text(json.dumps({"bomFormat": "CycloneDX", "components": []}), encoding="utf-8") - with pytest.raises(ConfigError, match="no metadata.component"): + with pytest.raises(ReleaseError, match="no metadata.component"): verify_release_sbom(wheel_path=wheel, sbom_path=sbom_path) -def test_publication_rechecks_the_sbom_binding_before_uploading() -> None: - publish = _load_workflow("release.yml")["jobs"]["publish"] +def test_sbom_describing_the_subject_twice_is_rejected(tmp_path: Path) -> None: + """Promoting the wheel into `metadata.component` while leaving it in + `components` would leave consumers unable to tell which node the document + is about, and the dependency graph keyed to the wrong one.""" - assert _step_index(publish, "scripts/release_sbom.py verify") < _step_index( - publish, "uv publish" + wheel = _write_wheel(tmp_path / WHEEL_FILENAME) + sbom_path = tmp_path / "sbom.json" + sbom_path.write_text( + json.dumps(_sbom(["agents-shipgate", "pydantic"], digest=_digest(wheel))), encoding="utf-8" ) + with pytest.raises(ReleaseError, match="described twice"): + verify_release_sbom(wheel_path=wheel, sbom_path=sbom_path) + + +def test_sbom_subject_must_have_exactly_one_dependency_node(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path / WHEEL_FILENAME) + sbom_path = tmp_path / "sbom.json" + sbom_path.write_text( + json.dumps( + _sbom( + ["pydantic"], + digest=_digest(wheel), + dependencies=[{"ref": "pydantic==1.0", "dependsOn": []}], + ) + ), + encoding="utf-8", + ) + + with pytest.raises(ReleaseError, match="dependency nodes for the declared subject"): + verify_release_sbom(wheel_path=wheel, sbom_path=sbom_path) + + sbom_path.write_text( + json.dumps( + _sbom( + ["pydantic"], + digest=_digest(wheel), + dependencies=[{"ref": "agents-shipgate==9.9.9", "dependsOn": ["pydantic==1.0"]}], + ) + ), + encoding="utf-8", + ) + assert verify_release_sbom(wheel_path=wheel, sbom_path=sbom_path) + + +def test_staging_rechecks_the_sbom_binding_before_publication() -> None: + release = _load_workflow("release.yml") + + assert "scripts/release_sbom.py verify" in _job_commands(release["jobs"]["stage"]) + assert release["jobs"]["publish"]["needs"] == ["verify", "stage"] + # -------------------------------------------------------------------------- # #343 — separated verification, recoverable publication # -------------------------------------------------------------------------- -def test_verification_and_publication_are_separate_jobs() -> None: +def test_the_pipeline_separates_verification_staging_publication_and_finalisation() -> None: release = _load_workflow("release.yml") - assert set(release["jobs"]) == {"verify", "publish"} + assert list(release["jobs"]) == ["verify", "stage", "publish", "finalize"] assert release["jobs"]["verify"]["uses"] == "./.github/workflows/release-verify.yml" - assert release["jobs"]["publish"]["needs"] == "verify" + assert release["jobs"]["stage"]["needs"] == "verify" + assert release["jobs"]["publish"]["needs"] == ["verify", "stage"] + assert release["jobs"]["finalize"]["needs"] == ["verify", "stage", "publish"] + +def test_write_and_oidc_authority_are_never_held_together() -> None: + """A job that can mint a PyPI token must not also be able to rewrite the + repository, and vice versa.""" -def test_permissions_are_least_privilege() -> None: release = _load_workflow("release.yml") verify_workflow = _load_workflow("release-verify.yml") - # No ambient authority at workflow level. assert release["permissions"] == {} - # Verification is read-only, in both the caller and the called workflow. assert release["jobs"]["verify"]["permissions"] == {"contents": "read"} assert verify_workflow["permissions"] == {"contents": "read"} assert verify_workflow["jobs"]["verify"]["permissions"] == {"contents": "read"} - assert "id-token" not in verify_workflow["jobs"]["verify"]["permissions"] - # Write and OIDC are scoped to publication alone. - assert release["jobs"]["publish"]["permissions"] == { - "contents": "write", - "id-token": "write", - } + + assert release["jobs"]["stage"]["permissions"] == {"contents": "write", "actions": "read"} + assert release["jobs"]["publish"]["permissions"] == {"id-token": "write"} + assert release["jobs"]["finalize"]["permissions"] == {"contents": "write"} + + for job_name in ("verify", "stage", "finalize"): + assert "id-token" not in release["jobs"][job_name].get("permissions", {}) + assert "contents" not in release["jobs"]["publish"]["permissions"] assert release["jobs"]["publish"]["environment"] == "pypi" +def test_the_token_bearing_job_installs_no_project_code() -> None: + """A compromised build backend or dev dependency in the job holding + `id-token: write` could request the PyPI token directly.""" + + publish = _load_workflow("release.yml")["jobs"]["publish"] + commands = _job_commands(publish) + + assert "pip install -e" not in commands + assert '".[dev]"' not in commands + # Installs only the hash-locked closure, and verifies hashes on install. + assert "--require-hashes" in commands + assert "constraints/release-publish.txt" in json.dumps(publish) + # No checkout of the repository at all. + assert not any("actions/checkout" in str(step.get("uses", "")) for step in publish["steps"]) + + +def test_the_publication_toolchain_is_hash_locked() -> None: + lockfile = (REPO_ROOT / "constraints/release-publish.txt").read_text(encoding="utf-8") + + assert "sigstore==" in lockfile + assert "uv==" in lockfile + assert lockfile.count("--hash=sha256:") > 10 + # No ranged requirements: every line pins an exact version. + assert ">=" not in lockfile.replace("# ", "") + + def test_release_concurrency_is_serialised_across_the_pypi_project() -> None: concurrency = _load_workflow("release.yml")["concurrency"] @@ -277,59 +432,94 @@ def test_release_concurrency_is_serialised_across_the_pypi_project() -> None: assert concurrency["cancel-in-progress"] is False -def test_draft_release_exists_before_publication_and_is_finalised_after() -> None: - publish = _load_workflow("release.yml")["jobs"]["publish"] +def test_index_state_is_classified_before_any_release_is_mutated() -> None: + """A divergent version must fail with both registries untouched.""" + + stage = _load_workflow("release.yml")["jobs"]["stage"] + + assert _step_index(stage, "scripts/release_publication.py pypi-state") < _step_index( + stage, "gh release create" + ) + + +def test_only_draft_releases_are_ever_mutated() -> None: + """Clobbering a published release's assets on a re-run would replace public + bytes that PyPI can no longer be made to match.""" - draft_index = _step_index(publish, "--draft \\") - publish_index = _step_index(publish, "uv publish") - validate_index = _step_index(publish, "is missing required asset") - finalise_index = _step_index(publish, "--draft=false") + stage = _load_workflow("release.yml")["jobs"]["stage"] + commands = _job_commands(stage) - # A failure after the immutable upload must leave a discoverable draft - # holding the authoritative assets. - assert draft_index < publish_index < validate_index < finalise_index + assert "--json isDraft --jq .isDraft" in commands + # The published branch verifies and reports, and does not upload. + published_branch = commands.split("Published already", 1)[-1] + assert "gh release upload" not in published_branch + assert "leaving it untouched" in published_branch + + +def test_draft_release_exists_before_publication_and_is_finalised_after() -> None: + release = _load_workflow("release.yml") + stage = _job_commands(release["jobs"]["stage"]) + finalize = release["jobs"]["finalize"] + + assert "--draft \\" in stage + # A failure after the immutable upload leaves a discoverable draft holding + # the authoritative assets. + assert _step_index(finalize, "is missing required asset") < _step_index( + finalize, "--draft=false" + ) def test_publication_is_idempotence_aware() -> None: publish = _load_workflow("release.yml")["jobs"]["publish"] upload = publish["steps"][_step_index(publish, "uv publish")] - assert upload["if"] == "steps.index.outputs.should_publish == 'true'" + assert upload["if"] == "needs.stage.outputs.should_publish == 'true'" + + +def test_release_assets_are_uploaded_from_an_explicit_allowlist() -> None: + """`dist/*` would upload whatever happens to be in the directory.""" + + stage = _job_commands(_load_workflow("release.yml")["jobs"]["stage"]) + + assert 'gh release create "${RELEASE_TAG}" "${assets[@]}"' in stage + assert "dist/*" not in stage def test_pre_release_tags_are_not_promoted_to_latest() -> None: """The project tags betas (`v0.16.0b7`). Finalising those with `--latest` would advertise a pre-release as the current version.""" - publish = _load_workflow("release.yml")["jobs"]["publish"] - draft = publish["steps"][_step_index(publish, "--draft \\")]["run"] + release = _load_workflow("release.yml") - assert "--prerelease" in draft - assert "^v[0-9]+\\.[0-9]+\\.[0-9]+$" in draft - # Finalisation reuses the maturity decided at draft time rather than - # hardcoding one. - finalise = publish["steps"][_step_index(publish, "--draft=false")]["run"] - assert "RELEASE_MATURITY" in finalise + for job_name in ("stage", "finalize"): + commands = _job_commands(release["jobs"][job_name]) + assert "--prerelease" in commands, job_name + assert "^v[0-9]+\\.[0-9]+\\.[0-9]+$" in commands, job_name -def test_handoff_rejects_an_asset_swapped_after_verification(tmp_path: Path) -> None: - wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") +# -------------------------------------------------------------------------- +# #343 / review — the content-addressed handoff +# -------------------------------------------------------------------------- + + +def _manifest(tmp_path: Path, wheel: Path, extra: list[Path] | None = None) -> Path: + assets = [{"filename": wheel.name, "sha256": _digest(wheel)}] + for path in extra or []: + assets.append({"filename": path.name, "sha256": _digest(path)}) manifest = tmp_path / "candidate-manifest.json" - manifest.write_text( - json.dumps( - { - "release_tag": "v9.9.9", - "assets": [{"filename": wheel.name, "sha256": _digest(wheel)}], - } - ), - encoding="utf-8", - ) + manifest.write_text(json.dumps({"release_tag": "v9.9.9", "assets": assets}), encoding="utf-8") + return manifest + + +def test_handoff_rejects_an_asset_swapped_after_verification(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path / WHEEL_FILENAME) + manifest = _manifest(tmp_path, wheel) expected = _digest(manifest) assert verify_manifest(manifest_path=manifest, expected_sha256=expected) _write_wheel(wheel, {"agents_shipgate/_swapped.py": "z = 3\n"}) - with pytest.raises(ConfigError, match="does not match the verified"): + with pytest.raises(ReleaseError, match="does not match the verified"): verify_manifest(manifest_path=manifest, expected_sha256=expected) @@ -337,78 +527,167 @@ def test_handoff_rejects_a_manifest_rewritten_to_match_a_swap(tmp_path: Path) -> """The manifest digest travels through the job-output channel, so rewriting the manifest inside the artifact store does not help.""" - wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") - manifest = tmp_path / "candidate-manifest.json" - manifest.write_text( - json.dumps({"assets": [{"filename": wheel.name, "sha256": _digest(wheel)}]}), - encoding="utf-8", - ) + wheel = _write_wheel(tmp_path / WHEEL_FILENAME) + manifest = _manifest(tmp_path, wheel) verified_digest = _digest(manifest) _write_wheel(wheel, {"agents_shipgate/_swapped.py": "z = 3\n"}) - manifest.write_text( - json.dumps({"assets": [{"filename": wheel.name, "sha256": _digest(wheel)}]}), - encoding="utf-8", - ) + _manifest(tmp_path, wheel) - with pytest.raises(ConfigError, match="artifact handoff was modified"): + with pytest.raises(ReleaseError, match="artifact handoff was modified"): verify_manifest(manifest_path=manifest, expected_sha256=verified_digest) -@pytest.mark.parametrize( - ("files", "expected_state", "should_publish"), - [ - ([], "absent", True), - ([{"digests": {"sha256": "MATCH"}}], "published_identical", False), - ], -) -def test_pypi_state_classification( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - files: list[dict[str, Any]], - expected_state: str, - should_publish: bool, +@pytest.mark.parametrize("supplied", ["", "not-a-digest", "abc123"]) +def test_a_missing_or_malformed_expected_digest_never_passes(tmp_path: Path, supplied: str) -> None: + """A truthiness check would fail open here: a redacted or absent job output + arrives as the empty string, and the workflow still passes the flag.""" + + wheel = _write_wheel(tmp_path / WHEEL_FILENAME) + manifest = _manifest(tmp_path, wheel) + + with pytest.raises(ReleaseError, match="not a 64-character lowercase SHA-256"): + verify_manifest(manifest_path=manifest, expected_sha256=supplied) + + +def test_the_expected_digest_flag_is_mandatory_on_the_command_line() -> None: + import subprocess + import sys + + result = subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "scripts" / "release_publication.py"), + "verify-manifest", + "--manifest", + "missing.json", + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert "--expected-sha256" in result.stderr + + +def test_handoff_rejects_files_the_manifest_does_not_list(tmp_path: Path) -> None: + """An intact manifest beside an unlisted sdist would otherwise be uploaded + unverified.""" + + wheel = _write_wheel(tmp_path / WHEEL_FILENAME) + manifest = _manifest(tmp_path, wheel) + expected = _digest(manifest) + (tmp_path / "agents_shipgate-9.9.9.tar.gz").write_text("smuggled", encoding="utf-8") + + with pytest.raises(ReleaseError, match="does not list"): + verify_manifest(manifest_path=manifest, expected_sha256=expected) + + # Signature bundles are permitted only by explicit name. + assert verify_manifest( + manifest_path=manifest, + expected_sha256=expected, + allowed_extra={"agents_shipgate-9.9.9.tar.gz"}, + ) + + +# -------------------------------------------------------------------------- +# #343 / review — PyPI index classification +# -------------------------------------------------------------------------- + + +def _record( + filename: str = WHEEL_FILENAME, + sha256: str | None = None, + packagetype: str = "bdist_wheel", + yanked: bool = False, +) -> dict[str, Any]: + return { + "filename": filename, + "packagetype": packagetype, + "digests": {"sha256": sha256 or "PLACEHOLDER"}, + "yanked": yanked, + } + + +def _patch_index( + monkeypatch: pytest.MonkeyPatch, records: list[dict[str, Any]], digest: str ) -> None: - wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") - digest = _digest(wheel) resolved = [ - {"digests": {"sha256": digest if item["digests"]["sha256"] == "MATCH" else "other"}} - for item in files + {**item, "digests": {**item["digests"], "sha256": digest}} + if item.get("digests", {}).get("sha256") == "PLACEHOLDER" + else item + for item in records ] monkeypatch.setattr( "scripts.release_publication._fetch_release_files", lambda *a, **k: resolved ) + +def test_an_absent_version_is_published(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + wheel = _write_wheel(tmp_path / WHEEL_FILENAME) + _patch_index(monkeypatch, [], _digest(wheel)) + result = pypi_state(wheel_path=wheel) - assert result["state"] == expected_state - assert result["should_publish"] is should_publish + assert result["state"] == "absent" + assert result["should_publish"] is True -def test_republishing_a_version_with_different_bytes_is_fatal( +def test_the_exact_published_wheel_completes_an_interrupted_transaction( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") - monkeypatch.setattr( - "scripts.release_publication._fetch_release_files", - lambda *a, **k: [{"digests": {"sha256": "0" * 64}}], - ) + wheel = _write_wheel(tmp_path / WHEEL_FILENAME) + _patch_index(monkeypatch, [_record()], _digest(wheel)) + + result = pypi_state(wheel_path=wheel) + + assert result["state"] == "published_identical" + assert result["should_publish"] is False + + +@pytest.mark.parametrize( + ("records", "why"), + [ + ( + [_record(), _record(filename="agents_shipgate-9.9.9.tar.gz", packagetype="sdist")], + "a divergent sdist alongside the matching wheel", + ), + ([_record(), _record(filename="agents_shipgate-9.9.9-py2-none-any.whl")], "a second wheel"), + ([_record(filename="renamed-9.9.9-py3-none-any.whl")], "a renamed file"), + ([_record(yanked=True)], "a yanked record"), + ([_record(packagetype="sdist")], "the wrong package type"), + ([{"filename": WHEEL_FILENAME, "packagetype": "bdist_wheel"}], "missing digests"), + ([_record(sha256="0" * 64)], "different bytes"), + ], +) +def test_anything_but_the_exact_single_wheel_is_divergent( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + records: list[dict[str, Any]], + why: str, +) -> None: + """Digest *membership* is not enough: skipping the upload and finalising + over any of these would ship a release this pipeline never verified.""" - with pytest.raises(ConfigError, match="already on the index with different bytes"): + wheel = _write_wheel(tmp_path / WHEEL_FILENAME) + _patch_index(monkeypatch, records, _digest(wheel)) + + with pytest.raises(ReleaseError, match="not as the single"): pypi_state(wheel_path=wheel) def test_an_unreachable_index_is_not_read_as_permission_to_upload( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - wheel = _write_wheel(tmp_path / "agents_shipgate-9.9.9-py3-none-any.whl") + wheel = _write_wheel(tmp_path / WHEEL_FILENAME) def _explode(*_args: Any, **_kwargs: Any) -> list[dict[str, Any]]: - raise ConfigError("Unable to query https://pypi.org/pypi: timed out") + raise ReleaseError("Unable to query https://pypi.org/pypi: timed out") monkeypatch.setattr("scripts.release_publication._fetch_release_files", _explode) - with pytest.raises(ConfigError, match="Unable to query"): + with pytest.raises(ReleaseError, match="Unable to query"): pypi_state(wheel_path=wheel) @@ -479,7 +758,7 @@ def test_perf_marker_is_declared_so_the_exclusion_is_meaningful() -> None: # -------------------------------------------------------------------------- -# #355 — the rehearsal cannot publish +# #355 — the rehearsal cannot publish, and is required # -------------------------------------------------------------------------- @@ -524,6 +803,37 @@ def test_rehearsal_is_manually_dispatchable_and_not_tag_triggered() -> None: assert set(triggers) == {"workflow_dispatch"} +def test_publication_requires_a_rehearsal_of_this_exact_candidate() -> None: + """#355 made rehearsal mandatory in the runbook; without enforcement that + acceptance criterion rests on operator discipline.""" + + stage = _load_workflow("release.yml")["jobs"]["stage"] + commands = _job_commands(stage) + + # Bound to the source tree (and therefore the workflow revision)... + assert "actions/workflows/release-rehearsal.yml/runs?head_sha=${SOURCE_SHA}" in commands + assert "status=success" in commands + assert "No successful Release Rehearsal run" in commands + # ...and to candidate identity, so a qualification artifact swapped between + # the rehearsal and the tag is caught. + assert "cmp -- rehearsed/candidate-manifest.json dist/candidate-manifest.json" in commands + assert _step_index(stage, "release-rehearsal.yml/runs") < _step_index( + stage, "gh release create" + ) + + +def test_every_rehearsal_proves_the_provenance_gate_fails_closed() -> None: + """The deliberate failure-path exercise is executed, not documented.""" + + verify = _load_workflow("release-verify.yml")["jobs"]["verify"] + step = verify["steps"][_step_index(verify, "fault-injected.whl")] + + assert step["if"] == "inputs.mode == 'rehearsal'" + assert "does not fail closed" in step["run"] + # It must assert the gate *rejects* the tampered wheel. + assert "if python scripts/verify_wheel_provenance.py" in step["run"] + + def test_rehearsal_publishes_inspectable_candidate_artifacts() -> None: verify = _load_workflow("release-verify.yml")["jobs"]["verify"] @@ -532,3 +842,43 @@ def test_rehearsal_publishes_inspectable_candidate_artifacts() -> None: assert _step_index(verify, "GITHUB_STEP_SUMMARY") > _step_index( verify, "actions/upload-artifact" ) + + +# -------------------------------------------------------------------------- +# Review — qualification trust roots are reviewed code, not mutable config +# -------------------------------------------------------------------------- + + +def test_signer_identity_and_issuer_are_not_mutable_configuration() -> None: + """An actor able to set variables could otherwise substitute fabricated + qualification evidence *and* replace the identity that authenticates it, in + one step. Source-to-wheel binding does not help: that attack reuses the + legitimate wheel and forges only the safety claims about it.""" + + verify_source = (WORKFLOWS / "release-verify.yml").read_text(encoding="utf-8") + + assert "vars.SAFETY_QUALIFICATION_SIGNER_IDENTITY" not in verify_source + assert "vars.SAFETY_QUALIFICATION_OIDC_ISSUER" not in verify_source + assert "steps.trust_roots.outputs.signer_identity" in verify_source + assert "steps.trust_roots.outputs.oidc_issuer" in verify_source + + # Only content-addressed locations stay mutable. + for name in ( + "SAFETY_QUALIFICATION_WHEEL_URL", + "SAFETY_QUALIFICATION_JSON_URL", + "SAFETY_QUALIFICATION_SIGSTORE_BUNDLE_URL", + ): + assert f"vars.{name}" in verify_source + + +def test_trust_roots_are_committed_and_fail_closed_while_unset() -> None: + roots = json.loads((REPO_ROOT / ".github/release-trust-roots.json").read_text("utf-8")) + + assert set(roots) >= {"signer_identity", "oidc_issuer"} + assert roots["oidc_issuer"].startswith("https://") + + commands = _job_commands(_load_workflow("release-verify.yml")["jobs"]["verify"]) + # An unset trust root must stop the release rather than default to + # something permissive. + assert '= "CHANGE_ME"' in commands + assert "configure the qualification trust root before releasing" in commands diff --git a/tests/test_safety_qualification_release.py b/tests/test_safety_qualification_release.py index 092f4397..bbe8fed6 100644 --- a/tests/test_safety_qualification_release.py +++ b/tests/test_safety_qualification_release.py @@ -324,7 +324,7 @@ def test_release_workflow_reuses_signed_qualified_wheel_before_publish() -> None # graph rather than by step position. assert "uv publish --trusted-publishing always" not in verify parsed_release = yaml.safe_load(release) - assert parsed_release["jobs"]["publish"]["needs"] == "verify" + assert parsed_release["jobs"]["publish"]["needs"] == ["verify", "stage"] # The wheel is addressed by the filename verification approved, and the # source-built wheel never enters the publishable set. From 1cad10933153c4bddfeef4afdcede68f696427bf Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Sun, 9 Aug 2026 19:54:05 -0700 Subject: [PATCH 5/8] fix(release): correct the output export bug and close the round-2 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first finding is a defect I introduced in a6eddfde: `source_sha` was declared as a job output but never added to `workflow_call.outputs`, so every `needs.verify.outputs.source_sha` in release.yml resolved to the empty string and the stage tag comparison would have failed on the first real release. Now exported, with a contract test asserting every caller-consumed output is publicly declared — the class of bug, not just this instance. Verification splits into `tests` and `artifact`. Sealing the handoff in the same job that ran pytest meant the qualified wheel stayed writable, with its path in GITHUB_ENV, while candidate tests and plugins executed; a test could replace the bytes after the equality check and before sealing, and the provenance report would still claim equality. The `artifact` job runs no suite and no audit, and re-asserts the binding on the exact bytes it seals. Publication no longer trusts stage's pre-approval snapshot. The index is reclassified inside the upload attempt, so a re-run after a post-upload failure skips and continues instead of retrying an immutable version and never reaching recovery. An already-published release is now left entirely alone: stage records `release_state`, and `publish` and `finalize` do not run when it is `published`. Re-signing would replace public, non-reproducible Sigstore bundles for no benefit. A published release with an absent index is treated as registries disagreeing and stops the run. Finalization verifies bytes rather than names: it re-peels the tag before any mutation and again immediately before undrafting, requires the release to still be a draft, downloads every remote asset and re-derives it against the trusted manifest digest closed-world, and verifies both signature bundles against the release workflow's Sigstore identity. It also fetches its stdlib-only scripts by immutable SHA instead of checking out, so no project code runs in a job that mutates a public release. Runbook documents protected `v*` tags as a repository prerequisite: the re-peel checks detect a moved tag, but detection is weaker than prevention. Co-Authored-By: Claude Opus 5 --- .github/workflows/release-verify.yml | 161 ++++++++++++++-------- .github/workflows/release.yml | 189 +++++++++++++++++++++----- docs/release-runbook.md | 73 +++++++--- tests/test_release_pipeline.py | 196 +++++++++++++++++++++++---- 4 files changed, 486 insertions(+), 133 deletions(-) diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index 21d77949..c705b610 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -32,43 +32,121 @@ on: outputs: version: description: Package version of the verified candidate. - value: ${{ jobs.verify.outputs.version }} + value: ${{ jobs.artifact.outputs.version }} release_tag: description: Tag the verified candidate is bound to. - value: ${{ jobs.verify.outputs.release_tag }} + value: ${{ jobs.artifact.outputs.release_tag }} + source_sha: + description: >- + Commit the candidate was actually built from, resolved after + checkout. Callers re-peel the tag against this before every + irreversible step. + value: ${{ jobs.artifact.outputs.source_sha }} wheel_filename: description: Basename of the qualified wheel. - value: ${{ jobs.verify.outputs.wheel_filename }} + value: ${{ jobs.artifact.outputs.wheel_filename }} wheel_sha256: description: SHA-256 of the wheel that verification approved. - value: ${{ jobs.verify.outputs.wheel_sha256 }} + value: ${{ jobs.artifact.outputs.wheel_sha256 }} manifest_sha256: description: >- SHA-256 of the candidate manifest. Travels through the job-output channel rather than the artifact store, so the publication job can detect artifact substitution. - value: ${{ jobs.verify.outputs.manifest_sha256 }} + value: ${{ jobs.artifact.outputs.manifest_sha256 }} artifact_name: description: Name of the uploaded candidate bundle. - value: ${{ jobs.verify.outputs.artifact_name }} + value: ${{ jobs.artifact.outputs.artifact_name }} permissions: contents: read jobs: - verify: + tests: + name: Correctness suite + runs-on: ubuntu-latest + # Observed on a hosted runner (CI run 31336011667, the same suite + # selection): the correctness suite is 407s and the supporting steps total + # ~40s. 20 minutes leaves roughly 2.5x headroom. Re-derive from rehearsal + # timings whenever the suite grows materially, and see + # docs/release-runbook.md before raising it after a single timeout. + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.12" + cache: pip + + - name: Install + run: python -m pip install -e ".[dev]" + + - name: Lint + run: python -m ruff check . + + - name: Compile + run: python -m compileall -q src tests + + - name: Verify generated schemas are up to date + run: python scripts/generate_schemas.py --check + + - name: Trust-model invariant lint (static, no user code execution) + # Kept as its own step, and excluded from the aggregate run below, so a + # release candidate that introduces a dynamic-import surface in an + # adapter fails with a named check rather than inside coverage noise. + run: python -m pytest tests/test_adapter_static_only.py -q + + - name: Test + # Deliberately differs from CI in two ways, and only two: + # + # * `-n auto` matches CI's supported parallelism. Serial execution + # made a release candidate spend the whole budget re-running work + # CI already parallelises. + # * `-m "not perf"` excludes the latency-budget tests. CI is the + # merge-time enforcement point for latency; those tests assert + # wallclock medians and are the one part of the suite that fails + # from shared-runner timing noise rather than from a defect. A + # release candidate must fail on deterministic correctness + # evidence, not on how busy the runner was. + # + # The coverage floor stays at CI's 85 so release cannot bypass it. + run: >- + python -m pytest -n auto -m "not perf" + --ignore=tests/test_adapter_static_only.py + --cov=agents_shipgate --cov-report=term-missing --cov-fail-under=85 + + - name: Dependency audit + run: python -m pip_audit . + + artifact: + name: Verify and seal the candidate + # Runs *after* the suite and in its own runner. Nothing here executes the + # candidate's tests, pytest plugins, or conftest code, which is the point: + # in a combined job the qualified wheel stayed writable on disk (and its + # path exported through GITHUB_ENV) while the suite ran, so a malicious or + # merely broken test could have replaced the bytes *after* the + # source-to-wheel equality check and before the handoff was sealed. The + # provenance report would still have claimed equality. + needs: tests runs-on: ubuntu-latest - # Measured, not estimated. Observed on a hosted runner (CI run 31336011667, - # the same suite selection this job uses): the correctness suite is 407s, - # and install + lint + compile + schema check + static lint + audit total - # ~40s. This job adds a source build, artifact download, signature and - # qualification verification, and the isolated SBOM install — together - # ~2 min, dominated by the SBOM environment's dependency install. - # - # That puts a healthy run near 10 minutes; 25 leaves roughly 2.5x headroom. - # Re-derive from the rehearsal timings whenever the suite grows materially, - # and see docs/release-runbook.md before raising it after a single timeout. - timeout-minutes: 25 + # Measured, not estimated. Observed on a hosted runner (CI run 31336011667): + # install ~14s, source build ~5s, dependency-free verification steps a few + # seconds each, and the isolated SBOM install ~1-2 min — it installs the + # wheel's whole runtime closure into a fresh environment and dominates this + # job. A healthy run is ~4 minutes; 15 leaves roughly 3.5x headroom. The + # correctness suite's budget lives on the `tests` job. Re-derive both from + # rehearsal timings, and see docs/release-runbook.md before raising either + # after a single timeout. + timeout-minutes: 15 permissions: contents: read outputs: @@ -307,43 +385,6 @@ jobs: echo "OK: provenance gate rejected a tampered wheel:" cat fault-injection.log - - name: Lint - run: python -m ruff check . - - - name: Compile - run: python -m compileall -q src tests - - - name: Verify generated schemas are up to date - run: python scripts/generate_schemas.py --check - - - name: Trust-model invariant lint (static, no user code execution) - # Kept as its own step, and excluded from the aggregate run below, so a - # release candidate that introduces a dynamic-import surface in an - # adapter fails with a named check rather than inside coverage noise. - run: python -m pytest tests/test_adapter_static_only.py -q - - - name: Test - # Deliberately differs from CI in two ways, and only two: - # - # * `-n auto` matches CI's supported parallelism. Serial execution - # made a release candidate spend the whole budget re-running work - # CI already parallelises. - # * `-m "not perf"` excludes the latency-budget tests. CI is the - # merge-time enforcement point for latency; those tests assert - # wallclock medians and are the one part of the suite that fails - # from shared-runner timing noise rather than from a defect. A - # release candidate must fail on deterministic correctness - # evidence, not on how busy the runner was. - # - # The coverage floor stays at CI's 85 so release cannot bypass it. - run: >- - python -m pytest -n auto -m "not perf" - --ignore=tests/test_adapter_static_only.py - --cov=agents_shipgate --cov-report=term-missing --cov-fail-under=85 - - - name: Dependency audit - run: python -m pip_audit . - - name: Generate wheel-scoped SBOM # Inventories an isolated runtime-only install of the qualified wheel. # `cyclonedx-py environment` over `.[dev]` described the CI environment @@ -366,6 +407,16 @@ jobs: SOURCE_SHA: ${{ steps.candidate.outputs.source_sha }} run: | set -euo pipefail + # Re-assert the binding on the exact bytes being sealed. The suite + # runs in a separate job so nothing here should have touched the + # wheel, but the handoff is the last point at which a substitution + # would still be invisible downstream — every later stage trusts + # these digests. + python scripts/verify_wheel_provenance.py \ + --built source-build/*.whl \ + --qualified "${QUALIFIED_WHEEL}" \ + --source-commit "${SOURCE_SHA}" \ + --report provenance.json cp -- "${QUALIFIED_WHEEL}" "candidate/${QUALIFIED_WHEEL_FILENAME}" cp -- qualified-dist/safety-qualification.json candidate/safety-qualification.json cp -- qualified-dist/safety-qualification.sigstore.json \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48d673f4..f3a2830d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,7 @@ jobs: outputs: should_publish: ${{ steps.index.outputs.should_publish }} index_state: ${{ steps.index.outputs.state }} + release_state: ${{ steps.release.outputs.release_state }} steps: - name: Checkout the verified source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 @@ -141,16 +142,23 @@ jobs: --github-output "${GITHUB_OUTPUT}" - name: Create or repair the draft release + id: release # A draft carrying the authoritative assets must exist before the # immutable PyPI upload, so a later failure leaves something to finish # from. Only drafts are ever mutated: an already-published release is # verified and left alone, because clobbering its assets on a re-run # would replace public bytes that PyPI can no longer be made to match. + # + # `release_state` is propagated so the downstream jobs can stand down + # entirely rather than re-signing and re-uploading over a completed + # transaction — Sigstore bundles are not reproducible, so re-signing a + # published release replaces public attestations for no reason. env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} MANIFEST_SHA256: ${{ needs.verify.outputs.manifest_sha256 }} + INDEX_STATE: ${{ steps.index.outputs.state }} run: | set -euo pipefail # `v1.2.3` is a final release; anything else (v0.16.0b7, rc, a) is a @@ -178,12 +186,14 @@ jobs: "${maturity}" \ --title "${RELEASE_TAG}" \ --notes "Agents Shipgate ${RELEASE_TAG}" + echo "release_state=absent" >> "${GITHUB_OUTPUT}" echo "Created draft release ${RELEASE_TAG}." exit 0 fi if [ "$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft)" = "true" ]; then gh release upload "${RELEASE_TAG}" "${assets[@]}" --clobber + echo "release_state=draft" >> "${GITHUB_OUTPUT}" echo "Repaired existing draft ${RELEASE_TAG}." exit 0 fi @@ -191,6 +201,10 @@ jobs: # Published already. Prove it carries exactly these bytes, then stop # touching it. Signature bundles are produced after staging, so they # are the only additional files permitted. + if [ "${INDEX_STATE}" != "published_identical" ]; then + echo "::error::Release ${RELEASE_TAG} is published on GitHub but the index state is ${INDEX_STATE}; the registries disagree. See docs/release-runbook.md." + exit 1 + fi mkdir -p published gh release download "${RELEASE_TAG}" --dir published --clobber python scripts/release_publication.py verify-manifest \ @@ -199,11 +213,16 @@ jobs: --directory published \ --allow "${WHEEL_FILENAME}.sigstore.json" \ --allow agents-shipgate-sbom.json.sigstore.json + echo "release_state=published" >> "${GITHUB_OUTPUT}" echo "Release ${RELEASE_TAG} is already published with the verified assets; leaving it untouched." publish: name: Publish to PyPI needs: [verify, stage] + # A completed transaction is left alone. Re-signing a published release + # would mint fresh, non-reproducible Sigstore bundles and replace the + # public attestations for no benefit. + if: needs.stage.outputs.release_state != 'published' runs-on: ubuntu-latest timeout-minutes: 15 # The human gate sits here, on the one irreversible action, and after the @@ -223,14 +242,23 @@ jobs: - name: Install the hash-locked publication toolchain env: - LOCKFILE_URL: https://raw.githubusercontent.com/${{ github.repository }}/${{ needs.verify.outputs.source_sha }}/constraints/release-publish.txt + RAW_BASE: https://raw.githubusercontent.com/${{ github.repository }}/${{ needs.verify.outputs.source_sha }} run: | set -euo pipefail # Fetched by immutable commit SHA rather than checked out, so no - # project code lands in the token-bearing job. - curl --fail --location --proto '=https' --proto-redir '=https' \ - --output release-publish.txt "${LOCKFILE_URL}" - python -m pip install --require-hashes --requirement release-publish.txt + # project code lands in the token-bearing job. The two scripts are + # standard-library only, so nothing beyond the locked toolchain is + # ever imported here. + mkdir -p tools + for path in \ + constraints/release-publish.txt \ + scripts/release_publication.py \ + scripts/_release_support.py + do + curl --fail --location --proto '=https' --proto-redir '=https' \ + --output "tools/$(basename "${path}")" "${RAW_BASE}/${path}" + done + python -m pip install --require-hashes --requirement tools/release-publish.txt - name: Confirm the tag still points at the verified commit env: @@ -270,14 +298,32 @@ jobs: dist/agents-shipgate-sbom.json - name: Publish to PyPI with Trusted Publishing - # Skipped when the index already holds these exact bytes, which is what - # a re-run after a post-publication failure looks like. `stage` - # hard-fails if the version exists with anything else, so this can never - # quietly succeed against a divergent artifact. - if: needs.stage.outputs.should_publish == 'true' + # The index is reclassified here rather than reusing `stage`'s result. + # That result predates environment approval and, on "Re-run failed + # jobs" after a post-upload step failed, would still say `absent` — + # retrying an immutable version and never reaching recovery. Deciding + # inside the attempt makes the three outcomes exact at the moment they + # matter: absent -> upload, identical -> skip and continue, divergent + # -> fail. env: WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} - run: uv publish --trusted-publishing always "dist/${WHEEL_FILENAME}" + run: | + set -euo pipefail + state="$(python tools/release_publication.py pypi-state \ + --wheel "dist/${WHEEL_FILENAME}" --github-output /dev/null)" + echo "${state}" + case "${state}" in + *"is absent"*) + uv publish --trusted-publishing always "dist/${WHEEL_FILENAME}" + ;; + *"is published_identical"*) + echo "Index already holds these exact bytes; completing the interrupted transaction." + ;; + *) + echo "::error::Unexpected index state before upload: ${state}" + exit 1 + ;; + esac - name: Upload signatures for finalisation uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -290,6 +336,8 @@ jobs: finalize: name: Finalise release needs: [verify, stage, publish] + # Nothing to finalise when the transaction already completed. + if: needs.stage.outputs.release_state != 'published' runs-on: ubuntu-latest timeout-minutes: 15 # Repository write, and no `id-token`: a dependency compromised here cannot @@ -297,17 +345,47 @@ jobs: permissions: contents: write steps: - - name: Checkout the verified source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: ${{ needs.verify.outputs.source_sha }} - persist-credentials: false - - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 with: python-version: "3.12" + - name: Install the hash-locked verification toolchain + env: + RAW_BASE: https://raw.githubusercontent.com/${{ github.repository }}/${{ needs.verify.outputs.source_sha }} + run: | + set -euo pipefail + # Same fetch-by-immutable-SHA approach as the publish job: this job + # mutates a public release, so it runs no project code either. + mkdir -p tools + for path in \ + constraints/release-publish.txt \ + scripts/release_publication.py \ + scripts/_release_support.py + do + curl --fail --location --proto '=https' --proto-redir '=https' \ + --output "tools/$(basename "${path}")" "${RAW_BASE}/${path}" + done + python -m pip install --require-hashes --requirement tools/release-publish.txt + + - name: Confirm the tag still points at the verified commit + # Re-peeled before any mutation here. PyPI has the bytes for source A + # by this point; if the tag moved to B, GitHub's source archives and + # the release would resolve to different code than the index holds. + env: + RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} + SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} + run: | + set -euo pipefail + peeled="$(git ls-remote "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${RELEASE_TAG}^{}" | cut -f1)" + if [ -z "${peeled}" ]; then + peeled="$(git ls-remote "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${RELEASE_TAG}" | cut -f1)" + fi + if [ "${peeled}" != "${SOURCE_SHA}" ]; then + echo "::error::Tag ${RELEASE_TAG} now points at ${peeled:-}, not the verified ${SOURCE_SHA}." + exit 1 + fi + - name: Download verified candidate uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: @@ -324,45 +402,88 @@ jobs: env: WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} run: | - python scripts/release_publication.py pypi-state \ + python tools/release_publication.py pypi-state \ --wheel "dist/${WHEEL_FILENAME}" | tee /dev/stderr | grep -q "published_identical" + - name: Require the release to still be a draft + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} + run: | + set -euo pipefail + if [ "$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft)" != "true" ]; then + echo "::error::Release ${RELEASE_TAG} is no longer a draft; refusing to mutate a published release." + exit 1 + fi + - name: Attach signatures env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} run: gh release upload "${RELEASE_TAG}" signatures/*.sigstore.json --clobber - - name: Validate release assets + - name: Validate the exact remote asset set, byte for byte + # Name membership was not enough. Draft repair clobbers expected names + # but leaves unlisted ones behind, and an asset can be replaced during + # the approval window, so the published bytes are re-derived here + # rather than assumed from the staging run. env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} + MANIFEST_SHA256: ${{ needs.verify.outputs.manifest_sha256 }} run: | set -euo pipefail - attached="$(gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name')" - for required in \ - "${WHEEL_FILENAME}" \ - "${WHEEL_FILENAME}.sigstore.json" \ - agents-shipgate-sbom.json \ - agents-shipgate-sbom.json.sigstore.json \ - safety-qualification.json \ - provenance.json \ - candidate-manifest.json - do - if ! printf '%s\n' "${attached}" | grep -qx -- "${required}"; then - echo "::error::Release ${RELEASE_TAG} is missing required asset ${required}." - exit 1 - fi + rm -rf remote && mkdir -p remote + gh release download "${RELEASE_TAG}" --dir remote --clobber + # Closed-world: the remote set must be exactly the candidate assets + # plus the two signature bundles, and every listed byte must match + # the digest the verification job recorded. + python tools/release_publication.py verify-manifest \ + --manifest remote/candidate-manifest.json \ + --expected-sha256 "${MANIFEST_SHA256}" \ + --directory remote \ + --allow "${WHEEL_FILENAME}.sigstore.json" \ + --allow agents-shipgate-sbom.json.sigstore.json + echo "OK: the remote asset set matches the verified candidate exactly." + + - name: Verify the attached signatures + env: + RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} + WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} + run: | + set -euo pipefail + identity="https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@refs/tags/${RELEASE_TAG}" + for target in "${WHEEL_FILENAME}" agents-shipgate-sbom.json; do + sigstore verify identity \ + --bundle "remote/${target}.sigstore.json" \ + --cert-identity "${identity}" \ + --cert-oidc-issuer https://token.actions.githubusercontent.com \ + "remote/${target}" done - echo "OK: all required assets are attached to ${RELEASE_TAG}." + echo "OK: attached signatures verify against ${identity}." - name: Finalise GitHub release env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} + SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} run: | set -euo pipefail + # Re-peeled once more: undrafting is the moment the release becomes + # public, so the tag binding is confirmed as late as possible. + peeled="$(git ls-remote "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${RELEASE_TAG}^{}" | cut -f1)" + if [ -z "${peeled}" ]; then + peeled="$(git ls-remote "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${RELEASE_TAG}" | cut -f1)" + fi + if [ "${peeled}" != "${SOURCE_SHA}" ]; then + echo "::error::Tag ${RELEASE_TAG} moved to ${peeled:-} before finalisation; refusing to publish the release." + exit 1 + fi + if [ "$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft)" != "true" ]; then + echo "::error::Release ${RELEASE_TAG} is no longer a draft." + exit 1 + fi if [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then maturity="--latest" else diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 8733966e..1634bc29 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -9,14 +9,24 @@ For the packaging surface and post-release fan-out checks, see ## The pipeline -A release runs as four jobs with an explicit, content-addressed handoff. +A release runs as five jobs with an explicit, content-addressed handoff. | Job | Permissions | What it does | |---|---|---| -| `verify` | `contents: read` | Builds from the tagged source, validates the signed qualification, binds wheel to source, runs the correctness suite, audits dependencies, produces the wheel-scoped SBOM, uploads a candidate bundle | +| `verify` → `tests` | `contents: read` | Lint, compile, schema check, the correctness suite, dependency audit | +| `verify` → `artifact` | `contents: read` | Builds from the tagged source, validates the signed qualification, binds wheel to source, produces the wheel-scoped SBOM, seals and uploads the candidate bundle | | `stage` | `contents: write`, `actions: read` | Re-peels the tag, requires a matching rehearsal, re-derives every digest, classifies the index, creates or repairs the **draft** release | | `publish` | `id-token: write`, `environment: pypi` | Signs and uploads to PyPI once | -| `finalize` | `contents: write` | Attaches signatures, confirms the index, validates assets, undrafts | +| `finalize` | `contents: write` | Attaches signatures, re-verifies the remote bytes, undrafts | + +Verification is two jobs for a specific reason: **the job that seals the handoff +never runs the candidate's tests.** In a combined job the qualified wheel stayed +writable on disk — with its path exported through `GITHUB_ENV` — while pytest, +its plugins, and `conftest` code executed. A test could therefore replace the +wheel *after* the source-to-wheel equality check and before the handoff was +sealed, and the provenance report would still have claimed equality. The +`artifact` job runs no suite and no dependency audit, and re-asserts the binding +on the exact bytes it seals. The split is about which capabilities are ever held together. `publish` can mint a PyPI Trusted Publishing token, so it holds **no repository write**, @@ -39,10 +49,20 @@ push event and the checkout would build one commit while provenance recorded another. Every downstream binding is keyed to the SHA the verification job actually resolved with `git rev-parse HEAD`. -Because a tag can still move (or be deleted) *after* verification, both `stage` -and `publish` re-peel it against the remote immediately before acting, and the +Because a tag can still move (or be deleted) *after* verification, it is +re-peeled against the remote immediately before every irreversible step — in +`stage`, in `publish` before the upload, in `finalize` before touching the +release, and once more immediately before undrafting. Undrafting is the moment +the release becomes public, so the binding is confirmed as late as possible: by +then PyPI already holds the bytes for source A, and a tag moved to B would make +GitHub's source archives resolve to different code than the index serves. The draft is created with `gh release create --verify-tag`. +**Repository prerequisite:** protect `v*` tags with a ruleset that forbids +updates and deletions. The re-peel checks detect a moved tag and fail closed, +but detection is a weaker control than prevention, and they cannot help during +the window between GitHub resolving a ref and acting on it. + ### Which artifact is authoritative The **qualified wheel** — the one named by `SAFETY_QUALIFICATION_WHEEL_FILENAME` @@ -126,18 +146,19 @@ budget on drills. ### Re-deriving the timeout -`release-verify.yml` sets `timeout-minutes: 25`, derived from observed hosted- -runner timings rather than an estimate: +Each verification job is bounded separately, from observed hosted-runner +timings rather than an estimate: -| Phase | Observed | -|---|---| -| Correctness suite (`-n auto`, `not perf`) | 407s | -| Install, lint, compile, schema check, static lint, dependency audit | ~40s | -| Source build, artifact download, signature + qualification verification, isolated SBOM install | ~2 min | +| Job | Phase | Observed | Timeout | +|---|---|---|---| +| `tests` | correctness suite (`-n auto`, `not perf`) | 407s | 20 min | +| `tests` | install, lint, compile, schema check, static lint, audit | ~40s | | +| `artifact` | source build, downloads, signature + qualification + provenance | ~30s | 15 min | +| `artifact` | isolated SBOM install | ~1–2 min | | -A healthy run lands near 10 minutes, so 25 leaves roughly 2.5x headroom. The -suite dominates; the SBOM step is the second largest because it installs the -wheel's runtime closure into a fresh environment. +Each leaves roughly 2.5–3.5x headroom. The suite dominates its job; the SBOM +step dominates the other, because it installs the wheel's whole runtime closure +into a fresh environment. After any change that materially grows the suite, read the actual job duration from a rehearsal run and reset the timeout to roughly 2.5x it. Do not raise it @@ -181,10 +202,24 @@ that also carries a divergent sdist, a second wheel, a renamed file, or a yanked record is **not** treated as identical, because skipping the upload and finalising over it would ship a release this pipeline never verified. -A re-run also never mutates an already-published GitHub Release. It downloads -the published assets, proves they are the verified ones, and leaves them alone; -only drafts are repaired. Clobbering a published release's assets would replace -public bytes that immutable PyPI can no longer be made to match. +A re-run also never mutates an already-published GitHub Release. `stage` +downloads the published assets, proves they are the verified ones, records +`release_state=published`, and **`publish` and `finalize` do not run at all**. +Re-signing would mint fresh, non-reproducible Sigstore bundles and replace the +public attestations for no benefit; clobbering assets would replace public bytes +that immutable PyPI can no longer be made to match. + +The index is also reclassified *inside* the publish attempt rather than reusing +the decision `stage` made before environment approval. A stale `absent` would +otherwise make "Re-run failed jobs" retry an immutable version and never reach +recovery. + +Before undrafting, `finalize` downloads every remote asset and re-derives it +against the trusted manifest digest — closed-world apart from the two signature +bundles, which are themselves verified against the release workflow's Sigstore +identity. Asset *names* are not evidence: draft repair clobbers expected names +but leaves unlisted ones behind, and an asset can be replaced during the +approval window. If re-running is not possible, finalise by hand — the draft already has the authoritative assets: diff --git a/tests/test_release_pipeline.py b/tests/test_release_pipeline.py index 73099a79..40073b7a 100644 --- a/tests/test_release_pipeline.py +++ b/tests/test_release_pipeline.py @@ -167,7 +167,7 @@ def test_identical_bytes_under_a_different_filename_are_rejected( def test_release_verification_gates_publication_on_source_binding() -> None: - verify = _load_workflow("release-verify.yml")["jobs"]["verify"] + verify = _load_workflow("release-verify.yml")["jobs"]["artifact"] build_index = _step_index(verify, "python -m build --wheel") provenance_index = _step_index(verify, "scripts/verify_wheel_provenance.py") @@ -183,7 +183,7 @@ def test_build_backend_is_pinned_so_byte_equality_is_achievable() -> None: constraints = (REPO_ROOT / "constraints/release-build.txt").read_text(encoding="utf-8") assert "hatchling==" in constraints - verify = _load_workflow("release-verify.yml")["jobs"]["verify"] + verify = _load_workflow("release-verify.yml")["jobs"]["artifact"] build_step = verify["steps"][_step_index(verify, "python -m build --wheel")] assert build_step["env"]["PIP_CONSTRAINT"] == "constraints/release-build.txt" @@ -205,7 +205,7 @@ def test_verification_runs_against_an_immutable_commit_not_a_symbolic_ref() -> N def test_provenance_is_keyed_to_the_resolved_checkout_sha() -> None: - verify = _load_workflow("release-verify.yml")["jobs"]["verify"] + verify = _load_workflow("release-verify.yml")["jobs"]["artifact"] commands = _job_commands(verify) # Resolved after checkout rather than taken from the event. @@ -386,7 +386,8 @@ def test_write_and_oidc_authority_are_never_held_together() -> None: assert release["permissions"] == {} assert release["jobs"]["verify"]["permissions"] == {"contents": "read"} assert verify_workflow["permissions"] == {"contents": "read"} - assert verify_workflow["jobs"]["verify"]["permissions"] == {"contents": "read"} + assert verify_workflow["jobs"]["tests"]["permissions"] == {"contents": "read"} + assert verify_workflow["jobs"]["artifact"]["permissions"] == {"contents": "read"} assert release["jobs"]["stage"]["permissions"] == {"contents": "write", "actions": "read"} assert release["jobs"]["publish"]["permissions"] == {"id-token": "write"} @@ -464,16 +465,23 @@ def test_draft_release_exists_before_publication_and_is_finalised_after() -> Non assert "--draft \\" in stage # A failure after the immutable upload leaves a discoverable draft holding # the authoritative assets. - assert _step_index(finalize, "is missing required asset") < _step_index( - finalize, "--draft=false" - ) + assert _step_index(finalize, "verify-manifest") < _step_index(finalize, "--draft=false") -def test_publication_is_idempotence_aware() -> None: +def test_index_state_is_reclassified_inside_the_publish_attempt() -> None: + """Reusing stage's cached result would make a re-run after a post-upload + failure retry an immutable version and never reach recovery.""" + publish = _load_workflow("release.yml")["jobs"]["publish"] upload = publish["steps"][_step_index(publish, "uv publish")] - assert upload["if"] == "needs.stage.outputs.should_publish == 'true'" + # The decision is taken inside the step, from a fresh query. + assert "release_publication.py pypi-state" in upload["run"] + assert "if" not in upload + assert "is absent" in upload["run"] + assert "is published_identical" in upload["run"] + # Any other state stops rather than guessing. + assert "Unexpected index state before upload" in upload["run"] def test_release_assets_are_uploaded_from_an_explicit_allowlist() -> None: @@ -704,9 +712,7 @@ def _test_step_command(job: dict[str, Any], name: str) -> str: def test_release_matches_ci_parallelism_and_excludes_perf() -> None: - release_test = _test_step_command( - _load_workflow("release-verify.yml")["jobs"]["verify"], "Test" - ) + release_test = _test_step_command(_load_workflow("release-verify.yml")["jobs"]["tests"], "Test") ci_test = _test_step_command(_load_workflow("ci.yml")["jobs"]["test"], "Test") # Same supported parallelism as CI: a release candidate should not spend @@ -720,9 +726,7 @@ def test_release_matches_ci_parallelism_and_excludes_perf() -> None: def test_release_does_not_weaken_the_coverage_floor() -> None: - release_test = _test_step_command( - _load_workflow("release-verify.yml")["jobs"]["verify"], "Test" - ) + release_test = _test_step_command(_load_workflow("release-verify.yml")["jobs"]["tests"], "Test") ci_test = _test_step_command(_load_workflow("ci.yml")["jobs"]["test"], "Test") assert "--cov-fail-under=85" in release_test @@ -733,21 +737,23 @@ def test_adapter_static_only_lint_stays_covered_in_release() -> None: """It is excluded from the aggregate run, so it needs its own step or the trust-model invariant silently stops being checked at release time.""" - verify = _load_workflow("release-verify.yml")["jobs"]["verify"] - aggregate = _test_step_command(verify, "Test") + tests_job = _load_workflow("release-verify.yml")["jobs"]["tests"] + aggregate = _test_step_command(tests_job, "Test") assert "--ignore=tests/test_adapter_static_only.py" in aggregate - assert _step_index(verify, "tests/test_adapter_static_only.py -q") < _step_index( - verify, "--cov-fail-under=85" + assert _step_index(tests_job, "tests/test_adapter_static_only.py -q") < _step_index( + tests_job, "--cov-fail-under=85" ) def test_release_verification_timeout_is_documented_and_bounded() -> None: - verify = _load_workflow("release-verify.yml")["jobs"]["verify"] + workflow = _load_workflow("release-verify.yml") source = (WORKFLOWS / "release-verify.yml").read_text(encoding="utf-8") - assert verify["timeout-minutes"] == 25 - # The number has to be traceable to a measurement, not an estimate. + # The suite dominates one job; artifact sealing is much cheaper. Both are + # bounded, and neither number is an estimate. + assert workflow["jobs"]["tests"]["timeout-minutes"] == 20 + assert workflow["jobs"]["artifact"]["timeout-minutes"] == 15 assert "Measured, not estimated" in source @@ -825,7 +831,7 @@ def test_publication_requires_a_rehearsal_of_this_exact_candidate() -> None: def test_every_rehearsal_proves_the_provenance_gate_fails_closed() -> None: """The deliberate failure-path exercise is executed, not documented.""" - verify = _load_workflow("release-verify.yml")["jobs"]["verify"] + verify = _load_workflow("release-verify.yml")["jobs"]["artifact"] step = verify["steps"][_step_index(verify, "fault-injected.whl")] assert step["if"] == "inputs.mode == 'rehearsal'" @@ -835,7 +841,7 @@ def test_every_rehearsal_proves_the_provenance_gate_fails_closed() -> None: def test_rehearsal_publishes_inspectable_candidate_artifacts() -> None: - verify = _load_workflow("release-verify.yml")["jobs"]["verify"] + verify = _load_workflow("release-verify.yml")["jobs"]["artifact"] upload = verify["steps"][_step_index(verify, "actions/upload-artifact")] assert upload["with"]["if-no-files-found"] == "error" @@ -877,8 +883,148 @@ def test_trust_roots_are_committed_and_fail_closed_while_unset() -> None: assert set(roots) >= {"signer_identity", "oidc_issuer"} assert roots["oidc_issuer"].startswith("https://") - commands = _job_commands(_load_workflow("release-verify.yml")["jobs"]["verify"]) + commands = _job_commands(_load_workflow("release-verify.yml")["jobs"]["artifact"]) # An unset trust root must stop the release rather than default to # something permissive. assert '= "CHANGE_ME"' in commands assert "configure the qualification trust root before releasing" in commands + + +# -------------------------------------------------------------------------- +# Review round 2 — the handoff, the tag, and the public release +# -------------------------------------------------------------------------- + + +def test_every_caller_consumed_output_is_publicly_exported() -> None: + """A reusable workflow's callers can read only the outputs declared in its + `workflow_call.outputs` map. A job-level output that is not exported + resolves to the empty string in the caller, silently — which is how + `source_sha` shipped as a job output that no `needs.verify.outputs` + reference could ever see. + """ + + import re + + exported = set(_load_workflow("release-verify.yml")["on"]["workflow_call"]["outputs"]) + consumed = set( + re.findall( + r"needs\.verify\.outputs\.(\w+)", + (WORKFLOWS / "release.yml").read_text(encoding="utf-8"), + ) + ) + + assert consumed, "the caller consumes no outputs; the check would be vacuous" + assert consumed <= exported, f"not exported: {sorted(consumed - exported)}" + + +def test_the_handoff_is_sealed_by_a_job_that_runs_no_candidate_tests() -> None: + """In a combined job the qualified wheel stayed writable while the suite + ran, so a test could replace the bytes after the equality check and before + the handoff was sealed — with the provenance report still claiming + equality.""" + + workflow = _load_workflow("release-verify.yml") + artifact = workflow["jobs"]["artifact"] + commands = _job_commands(artifact) + + assert set(workflow["jobs"]) == {"tests", "artifact"} + assert artifact["needs"] == "tests" + # The sealing job runs no suite, no plugins, no audit. + assert "pytest" not in commands + assert "pip_audit" not in commands + # And the suite job never touches the qualified wheel. + tests_commands = _job_commands(workflow["jobs"]["tests"]) + assert "QUALIFIED_WHEEL" not in tests_commands + assert "verify_wheel_provenance" not in tests_commands + + +def test_the_binding_is_reasserted_on_the_exact_bytes_being_sealed() -> None: + artifact = _load_workflow("release-verify.yml")["jobs"]["artifact"] + handoff = artifact["steps"][_step_index(artifact, "release_publication.py manifest")]["run"] + + # Provenance is re-derived inside the sealing step, before the copy. + assert handoff.index("verify_wheel_provenance.py") < handoff.index("release_publication.py") + + +def test_a_completed_transaction_is_left_entirely_alone() -> None: + """Re-signing a published release would mint fresh, non-reproducible + Sigstore bundles and replace the public attestations for no benefit.""" + + release = _load_workflow("release.yml") + + for job in ("publish", "finalize"): + assert release["jobs"][job]["if"] == "needs.stage.outputs.release_state != 'published'" + assert release["jobs"]["stage"]["outputs"]["release_state"] == ( + "${{ steps.release.outputs.release_state }}" + ) + stage = _job_commands(release["jobs"]["stage"]) + for state in ("absent", "draft", "published"): + assert f"release_state={state}" in stage + + +def test_registry_disagreement_stops_the_release() -> None: + """A published GitHub release with an absent index is not a state to + recover from automatically.""" + + stage = _job_commands(_load_workflow("release.yml")["jobs"]["stage"]) + + assert 'if [ "${INDEX_STATE}" != "published_identical" ]' in stage + assert "the registries disagree" in stage + + +def test_finalisation_verifies_remote_bytes_not_asset_names() -> None: + """Draft repair clobbers expected names but leaves unlisted assets behind, + and an asset can be replaced during the approval window.""" + + finalize = _load_workflow("release.yml")["jobs"]["finalize"] + commands = _job_commands(finalize) + + # Every remote asset is downloaded and re-derived against the trusted + # manifest digest, closed-world apart from the two signature bundles. + assert "gh release download" in commands + assert "verify-manifest" in commands + assert '--expected-sha256 "${MANIFEST_SHA256}"' in commands + assert '--allow "${WHEEL_FILENAME}.sigstore.json"' in commands + assert "--allow agents-shipgate-sbom.json.sigstore.json" in commands + # The signature bundles are verified, not merely present. + assert "sigstore verify identity" in commands + assert _step_index(finalize, "sigstore verify identity") < _step_index( + finalize, "--draft=false" + ) + + +def test_finalisation_refuses_to_mutate_a_release_that_is_no_longer_a_draft() -> None: + finalize = _load_workflow("release.yml")["jobs"]["finalize"] + commands = _job_commands(finalize) + + assert commands.count("--json isDraft --jq .isDraft") >= 2 + assert _step_index(finalize, "refusing to mutate a published release") < _step_index( + finalize, "gh release upload" + ) + + +def test_the_tag_is_rebound_before_finalisation_and_again_before_undrafting() -> None: + """PyPI holds the bytes for source A by this point; if the tag moves to B, + GitHub's source archives resolve to different code than the index holds.""" + + finalize = _load_workflow("release.yml")["jobs"]["finalize"] + commands = _job_commands(finalize) + + assert commands.count("git ls-remote") >= 2 + assert _step_index(finalize, "not the verified ${SOURCE_SHA}") < _step_index( + finalize, "gh release upload" + ) + undraft = finalize["steps"][_step_index(finalize, "--draft=false")]["run"] + assert "git ls-remote" in undraft + assert "moved to" in undraft + + +def test_finalisation_runs_no_project_code_either() -> None: + finalize = _load_workflow("release.yml")["jobs"]["finalize"] + commands = _job_commands(finalize) + + assert not any("actions/checkout" in str(step.get("uses", "")) for step in finalize["steps"]) + assert "pip install -e" not in commands + assert "--require-hashes" in commands + # Uses the stdlib-only scripts fetched by immutable SHA. + assert "tools/release_publication.py" in commands From 508b7d2ec9ad8e86b9cfc5deb39b517c5a1f7c63 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Sun, 9 Aug 2026 22:07:48 -0700 Subject: [PATCH 6/8] fix(release): harden the sealer's trust boundary and the publication windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 review. Five code changes and one honest limit. The sealing job installed the editable project and the ranged `dev` extra before building, verifying and sealing, so one compromised compatible release could have rewritten the verifier, both wheel copies, and the digests that delegate publication authority — with every later check agreeing. It now installs only constraints/release-seal.txt (build, hatchling, sigstore, cyclonedx-bom, full closure pinned and hashed) with --require-hashes, and no project code at all. The exhaustive policy re-derivation, which needs pydantic, moved to the `tests` gate; the sealer restates the decisive invariants — tier, qualified, production_qualified, static-only, 100 cases and receipts, zero unsafe auto-passes, and the wheel name/version/digest binding — with the standard library, so a signed-but-weakened artifact cannot pass on its signature alone. `verify_wheel_provenance.py` is now stdlib-only too, with a local PEP 427 filename parser replacing the `packaging` import. The OIDC job no longer runs any candidate code. It previously fetched `release_publication.py` from the candidate SHA and ran it before `uv publish`; "standard library only" is not a trust boundary when the file comes from the tree being released. The index is now classified with curl and jq, and the tag is re-peeled inside the upload step itself — download, digest check, signing and the index query all sat between the previous peel and the irreversible write. Stage's published branch used `--allow` for the signature bundles, which permits without requiring, so a release missing a bundle still reported `release_state=published` and both signature-verifying jobs skipped. It now `--require`s them and verifies both against the release workflow's Sigstore identity before declaring the transaction complete. The rehearsal no longer accepts a free-form ref: it passes `github.sha`, and the sealer asserts the suite ran against the same commit it is sealing. The remaining two windows — concurrent mutation between remote verification and undrafting, and post-publication asset replacement — cannot be closed by code here. docs/release-runbook.md gains a Deployment prerequisites table naming each setting, what it closes, and the residual without it, and states plainly that the workflow at a tag is itself candidate-controlled. Co-Authored-By: Claude Opus 5 --- .github/workflows/release-rehearsal.yml | 13 +- .github/workflows/release-verify.yml | 76 +- .github/workflows/release.yml | 117 ++- constraints/release-seal.in | 4 + constraints/release-seal.txt | 945 +++++++++++++++++++++ docs/release-runbook.md | 37 +- scripts/_release_support.py | 29 + scripts/build-llms-full.py | 5 +- scripts/generate_schemas.py | 12 +- scripts/github_action_annotations.py | 13 +- scripts/github_check_run.py | 12 +- scripts/release_publication.py | 41 +- scripts/run_benchmarks.py | 7 +- scripts/verify_qualification_binding.py | 172 ++++ scripts/verify_wheel_provenance.py | 26 +- tests/test_release_pipeline.py | 208 ++++- tests/test_safety_qualification_release.py | 18 +- 17 files changed, 1610 insertions(+), 125 deletions(-) create mode 100644 constraints/release-seal.in create mode 100644 constraints/release-seal.txt create mode 100644 scripts/verify_qualification_binding.py diff --git a/.github/workflows/release-rehearsal.yml b/.github/workflows/release-rehearsal.yml index f224683f..591cbfdd 100644 --- a/.github/workflows/release-rehearsal.yml +++ b/.github/workflows/release-rehearsal.yml @@ -25,11 +25,6 @@ name: Release Rehearsal on: workflow_dispatch: inputs: - ref: - description: Branch, tag or SHA to rehearse. Defaults to the dispatch ref. - required: false - type: string - default: "" release_tag: description: >- Tag to rehearse against. Leave empty to derive v, @@ -48,6 +43,12 @@ jobs: contents: read uses: ./.github/workflows/release-verify.yml with: - ref: ${{ inputs.ref || github.ref }} + # `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 diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index c705b610..9095c0cc 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -73,6 +73,8 @@ jobs: timeout-minutes: 20 permissions: contents: read + outputs: + source_sha: ${{ steps.tested.outputs.source_sha }} steps: - name: Checkout @@ -81,6 +83,10 @@ jobs: ref: ${{ inputs.ref }} persist-credentials: false + - name: Record the commit under test + id: tested + run: echo "source_sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" + - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 with: @@ -127,6 +133,33 @@ jobs: - name: Dependency audit run: python -m pip_audit . + - name: Exhaustive safety qualification policy re-derivation + # Re-derives every stratum, Wilson interval and confusion matrix in the + # signed artifact. It imports the project's pydantic schemas, so it + # belongs here, with the project installed, rather than in the sealing + # job — which restates the decisive invariants using only the standard + # library. This is a gate, not a producer: nothing the sealer trusts + # comes out of this job. + env: + QUALIFIED_WHEEL_URL: ${{ vars.SAFETY_QUALIFICATION_WHEEL_URL }} + QUALIFIED_WHEEL_FILENAME: ${{ vars.SAFETY_QUALIFICATION_WHEEL_FILENAME }} + QUALIFICATION_JSON_URL: ${{ vars.SAFETY_QUALIFICATION_JSON_URL }} + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + set -euo pipefail + version="$(sed -n 's/^version = "\(.*\)"$/\1/p' pyproject.toml | head -n 1)" + tag="${RELEASE_TAG:-v${version}}" + mkdir -p policy-dist + curl --fail --location --retry 3 --proto '=https' --proto-redir '=https' \ + --output "policy-dist/${QUALIFIED_WHEEL_FILENAME}" "${QUALIFIED_WHEEL_URL}" + curl --fail --location --retry 3 --proto '=https' --proto-redir '=https' \ + --output policy-dist/safety-qualification.json "${QUALIFICATION_JSON_URL}" + python scripts/verify_safety_qualification_release.py \ + --wheel "policy-dist/${QUALIFIED_WHEEL_FILENAME}" \ + --qualification policy-dist/safety-qualification.json \ + --tag "${tag}" + python -m twine check "policy-dist/${QUALIFIED_WHEEL_FILENAME}" + artifact: name: Verify and seal the candidate # Runs *after* the suite and in its own runner. Nothing here executes the @@ -276,8 +309,31 @@ jobs: python-version: "3.12" cache: pip - - name: Install - run: python -m pip install -e ".[dev]" + - name: Install the hash-locked sealing toolchain + # No editable install and no `dev` extra. An unlocked + # `pip install -e ".[dev]"` resolves dozens of packages by range and + # runs *before* the build, the qualification checks, the provenance + # comparison and the sealing — so one compromised compatible release + # could rewrite the verifier, both wheel copies, and the digests that + # delegate publication authority, with every later check agreeing. + run: | + python -m pip install --require-hashes \ + --requirement constraints/release-seal.txt + + - name: Confirm the sealer is verifying the tested commit + # `tests` and this job check out independently. If a caller passed a + # mutable ref, the two could resolve to different commits — testing A + # and sealing B. + env: + TESTED_SHA: ${{ needs.tests.outputs.source_sha }} + SOURCE_SHA: ${{ steps.candidate.outputs.source_sha }} + run: | + set -euo pipefail + if [ "${TESTED_SHA}" != "${SOURCE_SHA}" ]; then + echo "::error::The suite ran against ${TESTED_SHA} but this job resolved ${SOURCE_SHA}." + exit 1 + fi + echo "OK: suite and sealer agree on ${SOURCE_SHA}." - name: Build a wheel from the checked-out source # The provenance gate this feeds is the only thing tying the published @@ -333,15 +389,23 @@ jobs: --cert-oidc-issuer "${QUALIFICATION_OIDC_ISSUER}" \ qualified-dist/safety-qualification.json - - name: Verify production qualification and exact wheel binding + - name: Re-derive the decisive qualification invariants + # The exhaustive policy re-derivation (every stratum, Wilson interval + # and confusion matrix) imports the project's pydantic schemas, so it + # runs in the `policy` job where the project is installed. What is + # restated here, with the standard library only, are the claims that + # actually delegate publication authority: beta/qualified/ + # production_qualified, static-only, no failures, 100 cases and + # receipts, zero unsafe auto-passes, and the wheel name/version/digest + # binding. A signed-but-weakened artifact therefore cannot pass on the + # strength of its signature alone. env: RELEASE_TAG: ${{ steps.candidate.outputs.release_tag }} run: | - python scripts/verify_safety_qualification_release.py \ - --wheel "${QUALIFIED_WHEEL}" \ + python scripts/verify_qualification_binding.py \ --qualification qualified-dist/safety-qualification.json \ + --wheel "${QUALIFIED_WHEEL}" \ --tag "${RELEASE_TAG}" - python -m twine check "${QUALIFIED_WHEEL}" - name: Bind the qualified wheel to the tagged source tree # Without this the pipeline tests the checkout and publishes a wheel diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3a2830d..ec3b1aff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,6 +58,13 @@ jobs: with: python-version: "3.12" + - name: Install the hash-locked verification toolchain + # Needed to verify signature bundles on an already-published release. + # Hash-locked and project-free, like the other publication-side jobs. + run: | + python -m pip install --require-hashes \ + --requirement constraints/release-publish.txt + - name: Confirm the tag still points at the verified commit # Guards a tag moved (or deleted and recreated) after verification. # `^{}` peels annotated tags to the commit they name. @@ -207,14 +214,26 @@ jobs: fi mkdir -p published gh release download "${RELEASE_TAG}" --dir published --clobber + # `--require`, not `--allow`: declaring the transaction complete makes + # both downstream signature-verifying jobs skip, so a release missing + # a bundle — or carrying arbitrary bytes under the expected name — + # must fail here rather than be waved through. python scripts/release_publication.py verify-manifest \ --manifest published/candidate-manifest.json \ --expected-sha256 "${MANIFEST_SHA256}" \ --directory published \ - --allow "${WHEEL_FILENAME}.sigstore.json" \ - --allow agents-shipgate-sbom.json.sigstore.json + --require "${WHEEL_FILENAME}.sigstore.json" \ + --require agents-shipgate-sbom.json.sigstore.json + identity="https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@refs/tags/${RELEASE_TAG}" + for target in "${WHEEL_FILENAME}" agents-shipgate-sbom.json; do + sigstore verify identity \ + --bundle "published/${target}.sigstore.json" \ + --cert-identity "${identity}" \ + --cert-oidc-issuer https://token.actions.githubusercontent.com \ + "published/${target}" + done echo "release_state=published" >> "${GITHUB_OUTPUT}" - echo "Release ${RELEASE_TAG} is already published with the verified assets; leaving it untouched." + echo "Release ${RELEASE_TAG} is already published, complete and signed; leaving it untouched." publish: name: Publish to PyPI @@ -242,23 +261,22 @@ jobs: - name: Install the hash-locked publication toolchain env: - RAW_BASE: https://raw.githubusercontent.com/${{ github.repository }}/${{ needs.verify.outputs.source_sha }} + LOCKFILE_URL: https://raw.githubusercontent.com/${{ github.repository }}/${{ needs.verify.outputs.source_sha }}/constraints/release-publish.txt run: | set -euo pipefail - # Fetched by immutable commit SHA rather than checked out, so no - # project code lands in the token-bearing job. The two scripts are - # standard-library only, so nothing beyond the locked toolchain is - # ever imported here. - mkdir -p tools - for path in \ - constraints/release-publish.txt \ - scripts/release_publication.py \ - scripts/_release_support.py - do - curl --fail --location --proto '=https' --proto-redir '=https' \ - --output "tools/$(basename "${path}")" "${RAW_BASE}/${path}" - done - python -m pip install --require-hashes --requirement tools/release-publish.txt + # Only the lockfile is fetched, and `--require-hashes` means a + # substituted lockfile cannot install anything: every artifact must + # match a recorded digest. + # + # No candidate *code* runs in this job. An earlier revision fetched + # `release_publication.py` here and ran it before `uv publish`; + # "standard library only" is not a trust boundary, because the file + # still came from the candidate tree and could have rewritten the + # wheel or requested an OIDC token directly. The index query below + # therefore uses curl and jq, which the runner provides. + curl --fail --location --proto '=https' --proto-redir '=https' \ + --output release-publish.txt "${LOCKFILE_URL}" + python -m pip install --require-hashes --requirement release-publish.txt - name: Confirm the tag still points at the verified commit env: @@ -307,24 +325,65 @@ jobs: # -> fail. env: WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} + WHEEL_SHA256: ${{ needs.verify.outputs.wheel_sha256 }} + VERSION: ${{ needs.verify.outputs.version }} + RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} + SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} run: | set -euo pipefail - state="$(python tools/release_publication.py pypi-state \ - --wheel "dist/${WHEEL_FILENAME}" --github-output /dev/null)" - echo "${state}" - case "${state}" in - *"is absent"*) - uv publish --trusted-publishing always "dist/${WHEEL_FILENAME}" + # Classified with curl and jq rather than a candidate-tree helper: + # this job can mint a PyPI token, so it runs no repository code. + url="https://pypi.org/pypi/agents-shipgate/${VERSION}/json" + status="$(curl --silent --show-error --location --proto '=https' \ + --write-out '%{http_code}' --output index.json "${url}" || true)" + + case "${status}" in + 404) state="absent" ;; + 200) + # Exactly one unyanked wheel, expected filename, matching digest. + state="$(jq -r --arg f "${WHEEL_FILENAME}" --arg d "${WHEEL_SHA256}" ' + if (.urls | length) == 1 + and .urls[0].filename == $f + and .urls[0].packagetype == "bdist_wheel" + and .urls[0].digests.sha256 == $d + and (.urls[0].yanked != true) + then "published_identical" else "published_divergent" end + ' index.json)" + ;; + *) + echo "::error::Unable to query ${url}: HTTP ${status}. An unreachable index is not permission to upload." + exit 1 ;; - *"is published_identical"*) + esac + echo "Index state: ${state}" + + case "${state}" in + published_identical) echo "Index already holds these exact bytes; completing the interrupted transaction." + exit 0 ;; - *) - echo "::error::Unexpected index state before upload: ${state}" + published_divergent) + echo "::error::${VERSION} is already on the index with different content. PyPI uploads are immutable; cut a new version. See docs/release-runbook.md." exit 1 ;; esac + # Last possible moment: the tag is re-peeled immediately before the + # irreversible upload, not several steps earlier. Everything between + # the previous check and here (download, digest check, signing) is + # window in which an unprotected tag could have moved. + peeled="$(git ls-remote "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${RELEASE_TAG}^{}" | cut -f1)" + if [ -z "${peeled}" ]; then + peeled="$(git ls-remote "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${RELEASE_TAG}" | cut -f1)" + fi + if [ "${peeled}" != "${SOURCE_SHA}" ]; then + echo "::error::Tag ${RELEASE_TAG} points at ${peeled:-}, not the verified ${SOURCE_SHA}; refusing to publish." + exit 1 + fi + echo "${WHEEL_SHA256} dist/${WHEEL_FILENAME}" | sha256sum --check --strict + + uv publish --trusted-publishing always "dist/${WHEEL_FILENAME}" + - name: Upload signatures for finalisation uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: @@ -443,8 +502,8 @@ jobs: --manifest remote/candidate-manifest.json \ --expected-sha256 "${MANIFEST_SHA256}" \ --directory remote \ - --allow "${WHEEL_FILENAME}.sigstore.json" \ - --allow agents-shipgate-sbom.json.sigstore.json + --require "${WHEEL_FILENAME}.sigstore.json" \ + --require agents-shipgate-sbom.json.sigstore.json echo "OK: the remote asset set matches the verified candidate exactly." - name: Verify the attached signatures diff --git a/constraints/release-seal.in b/constraints/release-seal.in new file mode 100644 index 00000000..0ed65cfc --- /dev/null +++ b/constraints/release-seal.in @@ -0,0 +1,4 @@ +build==1.5.0 +hatchling==1.31.0 +sigstore==4.5.0 +cyclonedx-bom==7.3.1 diff --git a/constraints/release-seal.txt b/constraints/release-seal.txt new file mode 100644 index 00000000..2e6181c0 --- /dev/null +++ b/constraints/release-seal.txt @@ -0,0 +1,945 @@ +# Hash-locked toolchain for the sealing job. +# +# The sealer decides what gets published: it compares the wheel it builds from +# the tagged source against the qualified wheel, re-derives the decisive +# qualification invariants, generates the SBOM, and seals the digests that +# delegate publication authority to later jobs. +# +# It therefore installs *no* editable project and *no* ranged `dev` extra. An +# unlocked `pip install -e ".[dev]"` resolves dozens of packages by range; a +# single compromised compatible release could rewrite the verifier, both wheel +# copies, and the digests themselves, and every downstream check would agree. +# +# Only these four direct requirements, with their full transitive closure +# pinned and hashed, installed with `--require-hashes`: +# +# build + hatchling build the wheel from the tagged source (hatchling is +# pinned separately in release-build.txt for byte +# reproducibility; the pin here must match it) +# sigstore verify the qualification signature against the +# committed trust root +# cyclonedx-bom generate the wheel-scoped SBOM +# +# Regenerate with: +# +# uv pip compile --universal --generate-hashes --python-version 3.12 \ +# constraints/release-seal.in -o constraints/release-seal.txt +# +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + # via pydantic +arrow==1.4.0 \ + --hash=sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205 \ + --hash=sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7 + # via isoduration +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via + # jsonschema + # referencing +boolean-py==5.0 \ + --hash=sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95 \ + --hash=sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9 + # via license-expression +build==1.5.0 \ + --hash=sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f \ + --hash=sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647 + # via -r constraints/release-seal.in +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via requests +cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via cryptography +chardet==5.2.0 \ + --hash=sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7 \ + --hash=sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970 + # via cyclonedx-bom +charset-normalizer==3.4.9 \ + --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ + --hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \ + --hash=sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c \ + --hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \ + --hash=sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5 \ + --hash=sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833 \ + --hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \ + --hash=sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99 \ + --hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \ + --hash=sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec \ + --hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \ + --hash=sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4 \ + --hash=sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a \ + --hash=sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3 \ + --hash=sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2 \ + --hash=sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a \ + --hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \ + --hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \ + --hash=sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419 \ + --hash=sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84 \ + --hash=sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da \ + --hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \ + --hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \ + --hash=sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381 \ + --hash=sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29 \ + --hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \ + --hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \ + --hash=sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe \ + --hash=sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29 \ + --hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \ + --hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \ + --hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \ + --hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \ + --hash=sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94 \ + --hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \ + --hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \ + --hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \ + --hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \ + --hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \ + --hash=sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1 \ + --hash=sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15 \ + --hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \ + --hash=sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993 \ + --hash=sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4 \ + --hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \ + --hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \ + --hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \ + --hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \ + --hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \ + --hash=sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d \ + --hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \ + --hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \ + --hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \ + --hash=sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db \ + --hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \ + --hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \ + --hash=sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee \ + --hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \ + --hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \ + --hash=sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f \ + --hash=sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9 \ + --hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \ + --hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \ + --hash=sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8 \ + --hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \ + --hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \ + --hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \ + --hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \ + --hash=sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616 \ + --hash=sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d \ + --hash=sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba \ + --hash=sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2 \ + --hash=sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b \ + --hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \ + --hash=sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b \ + --hash=sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209 \ + --hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \ + --hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \ + --hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \ + --hash=sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a \ + --hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \ + --hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \ + --hash=sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b \ + --hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \ + --hash=sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5 \ + --hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \ + --hash=sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9 \ + --hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \ + --hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \ + --hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \ + --hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \ + --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ + --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 + # via requests +colorama==0.4.6 ; os_name == 'nt' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via build +cryptography==50.0.0 \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 + # via + # pyopenssl + # rfc3161-client + # sigstore +cyclonedx-bom==7.3.1 \ + --hash=sha256:8c5adccefd593e89c0500463649c232625cee8e38dae1a433e057dfa495eff08 \ + --hash=sha256:ebd99dc2cf62a7067e327bef2b2ffada99d57ed3fda2db6c4c8f3004e5bfd3cf + # via -r constraints/release-seal.in +cyclonedx-python-lib==11.11.0 \ + --hash=sha256:3049fc83e06a059b5c5907a527625a8ed5073caab10607ed4c9e5503b590fd44 \ + --hash=sha256:4b3194db72b613717f2912447e67ab618c75ff7dcac6c4af3c0e9e1ac617c102 + # via cyclonedx-bom +defusedxml==0.7.1 \ + --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \ + --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 + # via py-serializable +dnspython==2.8.0 \ + --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \ + --hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f + # via email-validator +email-validator==2.3.0 \ + --hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \ + --hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426 + # via pydantic +fqdn==1.5.1 \ + --hash=sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f \ + --hash=sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014 + # via jsonschema +hatchling==1.31.0 \ + --hash=sha256:6b48ad4068a482ed7239b3a8215bc55b47aad3345d58dfc94e553c5d2d46211b \ + --hash=sha256:aac80bec8b6fe35e8480f1c335be8910fa210a0e6f735a139be205dadcacb544 + # via -r constraints/release-seal.in +id==1.6.1 \ + --hash=sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069 \ + --hash=sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca + # via sigstore +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # email-validator + # jsonschema + # requests +isoduration==20.11.0 \ + --hash=sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9 \ + --hash=sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042 + # via jsonschema +jsonpointer==3.1.1 \ + --hash=sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900 \ + --hash=sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca + # via jsonschema +jsonschema==4.26.0 \ + --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + # via cyclonedx-python-lib +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via jsonschema +lark==1.3.1 \ + --hash=sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905 \ + --hash=sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12 + # via rfc3987-syntax +license-expression==30.4.4 \ + --hash=sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4 \ + --hash=sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd + # via cyclonedx-python-lib +lxml==6.1.1 \ + --hash=sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2 \ + --hash=sha256:07a4a68e286ee7a1ed7dfb8af83e615757c0ccfe9f18c6b4ea6771388d9ba8c9 \ + --hash=sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60 \ + --hash=sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c \ + --hash=sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7 \ + --hash=sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a \ + --hash=sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83 \ + --hash=sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072 \ + --hash=sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8 \ + --hash=sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462 \ + --hash=sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0 \ + --hash=sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085 \ + --hash=sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f \ + --hash=sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30 \ + --hash=sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1 \ + --hash=sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77 \ + --hash=sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740 \ + --hash=sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b \ + --hash=sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c \ + --hash=sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621 \ + --hash=sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e \ + --hash=sha256:32ab449a5486f6c758e849bb86710d0e45edc24a04e250c01555f8f5653958f8 \ + --hash=sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca \ + --hash=sha256:34c2d737beabfe35baada43941ed519251e9a12e779031496bcd5d539fcfd730 \ + --hash=sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245 \ + --hash=sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1 \ + --hash=sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004 \ + --hash=sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d \ + --hash=sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52 \ + --hash=sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5 \ + --hash=sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf \ + --hash=sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc \ + --hash=sha256:441dd227fa0690eb9fc81edabc63cdcefc212bba99b906dcf6e32cc1a9d3e533 \ + --hash=sha256:469e3618338bd7ab5beb412d2439825479fcf0dab99e394ca563dbc4eaf6c834 \ + --hash=sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947 \ + --hash=sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a \ + --hash=sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2 \ + --hash=sha256:53c909b62a0532183542fed00c5a7218258c56292d409bc789886fe1cb04c438 \ + --hash=sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc \ + --hash=sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea \ + --hash=sha256:55b03549819867ea141c0202242c4816c82e52ec36e7e648db9d8da5a3dc3ed6 \ + --hash=sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e \ + --hash=sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c \ + --hash=sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383 \ + --hash=sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955 \ + --hash=sha256:5bec7d03d78d853597d6107854c2310ce3f761fd218fe9fe91d5101fcf6c2efe \ + --hash=sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074 \ + --hash=sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c \ + --hash=sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a \ + --hash=sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb \ + --hash=sha256:639f6c857d91d9be29bd7502348d6736dab168b54b5158cd899abf11684dc186 \ + --hash=sha256:640f97d43d867bcb9c75b3af013b64850756b746cb6bce8ace83b70da3abba9d \ + --hash=sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1 \ + --hash=sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f \ + --hash=sha256:6689e828a94eee4f139408c337bb198e014724bb8a8c26d3cfac49d119ed69a6 \ + --hash=sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736 \ + --hash=sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6 \ + --hash=sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2 \ + --hash=sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b \ + --hash=sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7 \ + --hash=sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14 \ + --hash=sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009 \ + --hash=sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca \ + --hash=sha256:76447f65250ed2501ead1a1552f5ce8edff159a86f308348e6a9c4acb5e1f1b4 \ + --hash=sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635 \ + --hash=sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee \ + --hash=sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e \ + --hash=sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9 \ + --hash=sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603 \ + --hash=sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08 \ + --hash=sha256:83b6b30eb131da7a75b601f28c5d6971e6ed3e887919bf6b6a1ad3c2df289080 \ + --hash=sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525 \ + --hash=sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5 \ + --hash=sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f \ + --hash=sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067 \ + --hash=sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e \ + --hash=sha256:8be8ad51249698103d24b0571df35a10990fbe93dd043b6c024172189485f5e3 \ + --hash=sha256:8d43ca737b20e106e4aebc42b2f3ae19f00ba63d7eb731698ee083d72d15646f \ + --hash=sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485 \ + --hash=sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13 \ + --hash=sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383 \ + --hash=sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315 \ + --hash=sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e \ + --hash=sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c \ + --hash=sha256:9f76acfb5f68ba982635a53fd985a8044be98a35b43232c2a1ee235ffab3e1dd \ + --hash=sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099 \ + --hash=sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660 \ + --hash=sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510 \ + --hash=sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a \ + --hash=sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b \ + --hash=sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5 \ + --hash=sha256:aae97dfdb60715c164419ac2532a76d013c3918a665eb6cb7288098b5f349aaf \ + --hash=sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28 \ + --hash=sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00 \ + --hash=sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef \ + --hash=sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1 \ + --hash=sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955 \ + --hash=sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590 \ + --hash=sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137 \ + --hash=sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf \ + --hash=sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40 \ + --hash=sha256:bdebcc8a75d38c7598dfb2c9ed852d7a9eb4a10d6e2d0764b919b802bf32ac88 \ + --hash=sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e \ + --hash=sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840 \ + --hash=sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2 \ + --hash=sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca \ + --hash=sha256:c674693f055fa2495de12292cb45e9944199d8eaef5a2dec45175c7c61cb73e3 \ + --hash=sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465 \ + --hash=sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc \ + --hash=sha256:c9a4b821dc7055bf9e05ff5719e18ec501f75c0f0bbfabd573b277559780833d \ + --hash=sha256:c9f79d5325907f13e1be0b3e4dacc1049d1dffc4aeee3c995284bea5fe0fab7d \ + --hash=sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa \ + --hash=sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a \ + --hash=sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a \ + --hash=sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206 \ + --hash=sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e \ + --hash=sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785 \ + --hash=sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8 \ + --hash=sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a \ + --hash=sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b \ + --hash=sha256:e07c65f443c887bbcf31cc1771d932ecc192a5273943589b3c7572b749f1ffb2 \ + --hash=sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6 \ + --hash=sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6 \ + --hash=sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354 \ + --hash=sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818 \ + --hash=sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84 \ + --hash=sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909 \ + --hash=sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038 \ + --hash=sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d \ + --hash=sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2 \ + --hash=sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf \ + --hash=sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc \ + --hash=sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d \ + --hash=sha256:ffecec8eb889b58ba9be5b95fb1cc78e22ea8eedea38e8736a1568fe1979250e + # via cyclonedx-python-lib +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a + # via rich +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba + # via markdown-it-py +packageurl-python==0.17.6 \ + --hash=sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25 \ + --hash=sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9 + # via + # cyclonedx-bom + # cyclonedx-python-lib +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via + # build + # cyclonedx-bom + # hatchling + # pip-requirements-parser +pathspec==1.1.1 \ + --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \ + --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 + # via hatchling +pip-requirements-parser==32.0.1 \ + --hash=sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526 \ + --hash=sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3 + # via cyclonedx-bom +platformdirs==4.11.1 \ + --hash=sha256:2efd27d363e8dd2e661639ffb398865a5e0a46442a11d266bf375a0e0c10e386 \ + --hash=sha256:bb1af68078f25e2f3e111e2d43b8d536df41b73c8a684b40bb018223b66fae27 + # via sigstore +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via hatchling +py-serializable==2.1.0 \ + --hash=sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103 \ + --hash=sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304 + # via cyclonedx-python-lib +pyasn1==0.6.4 \ + --hash=sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81 \ + --hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b + # via sigstore +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pydantic==2.13.4 \ + --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ + --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 + # via + # sigstore + # sigstore-models + # sigstore-rekor-types +pydantic-core==2.46.4 \ + --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \ + --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \ + --hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \ + --hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \ + --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \ + --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \ + --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \ + --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \ + --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \ + --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \ + --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \ + --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \ + --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \ + --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \ + --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \ + --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \ + --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \ + --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \ + --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \ + --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \ + --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \ + --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \ + --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \ + --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \ + --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \ + --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \ + --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \ + --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \ + --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \ + --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \ + --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \ + --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \ + --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \ + --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \ + --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \ + --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \ + --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \ + --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \ + --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \ + --hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \ + --hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \ + --hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \ + --hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \ + --hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \ + --hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \ + --hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \ + --hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \ + --hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \ + --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \ + --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \ + --hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \ + --hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \ + --hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \ + --hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \ + --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \ + --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \ + --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \ + --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \ + --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \ + --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \ + --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \ + --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \ + --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \ + --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \ + --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \ + --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \ + --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \ + --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \ + --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \ + --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \ + --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \ + --hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \ + --hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \ + --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \ + --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \ + --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \ + --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \ + --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \ + --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \ + --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \ + --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \ + --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \ + --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \ + --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \ + --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \ + --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \ + --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \ + --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \ + --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \ + --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \ + --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \ + --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \ + --hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \ + --hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \ + --hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \ + --hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \ + --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \ + --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \ + --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \ + --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \ + --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \ + --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \ + --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \ + --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \ + --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \ + --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \ + --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \ + --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \ + --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \ + --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \ + --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \ + --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \ + --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \ + --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \ + --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \ + --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \ + --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \ + --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \ + --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \ + --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae + # via pydantic +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + # via rich +pyjwt==2.13.0 \ + --hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \ + --hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728 + # via sigstore +pyopenssl==26.4.0 \ + --hash=sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7 \ + --hash=sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c + # via sigstore +pyparsing==3.3.2 \ + --hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \ + --hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc + # via pip-requirements-parser +pyproject-hooks==1.2.0 \ + --hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \ + --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 + # via build +python-dateutil==2.9.0.post0 \ + --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 + # via arrow +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # via + # cyclonedx-python-lib + # jsonschema + # jsonschema-specifications +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed + # via sigstore +rfc3161-client==1.0.8 \ + --hash=sha256:29cebab8dadec88b85eac43505db186749e23ddf1c7699ffa08f7cbdc86a52fe \ + --hash=sha256:38c5c278c7d4605e9c166a332c13b286475730b750109235b547f7d1b21c5c6f \ + --hash=sha256:3951db9573e4f6b4a1a62ad4f0074683610714625bf50c010739ffa568f23beb \ + --hash=sha256:4bda5a2bc6947c16b6f8df90ff0e99cb333d78ab1465517f637d313d75703651 \ + --hash=sha256:4bdb12618f98ee634d3625208f4f1c3cdde2306a8187a7416bfa47d45cad3dba \ + --hash=sha256:51adc82dbd04d2b88e3a17f524f0e57d0270d7276887a5800c81833f79fb4f4a \ + --hash=sha256:55cd9366f20dcea8dc65f93b08d12607c071015d2f5b5d24129128f04643a77d \ + --hash=sha256:5c1889d6bae269dc0f1e418f82e554b413066b5f8dd864f9367cf1ffb3d0a312 \ + --hash=sha256:7f8b82c97c1935a09376591b45bd81aa57a4232f4d446eee3a44c025ef7c4d5a \ + --hash=sha256:9826227dc04e1a86f2598f9c1829f078afc35f558987df77578ea1508d1460a1 \ + --hash=sha256:9d382372e7fdfde592584f985fb1063d1506ae0573df45fcb2b41e2ed82e3431 \ + --hash=sha256:d3c25311c67a7daeef990fb5b94eaed706135c6a6fd98c6e382bbc857faa4214 \ + --hash=sha256:e95ca8a64fddfdd639e09e48bab4722f31b26ce099067ad2bb8e85f88fcc707b + # via sigstore +rfc3339-validator==0.1.4 \ + --hash=sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b \ + --hash=sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa + # via jsonschema +rfc3986-validator==0.1.1 \ + --hash=sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9 \ + --hash=sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055 + # via jsonschema +rfc3987-syntax==1.1.0 \ + --hash=sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f \ + --hash=sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d + # via jsonschema +rfc8785==0.1.4 \ + --hash=sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48 \ + --hash=sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da + # via sigstore +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ + --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 + # via sigstore +rpds-py==2026.6.3 \ + --hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \ + --hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \ + --hash=sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9 \ + --hash=sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538 \ + --hash=sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804 \ + --hash=sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf \ + --hash=sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4 \ + --hash=sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97 \ + --hash=sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6 \ + --hash=sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96 \ + --hash=sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a \ + --hash=sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187 \ + --hash=sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975 \ + --hash=sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f \ + --hash=sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703 \ + --hash=sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9 \ + --hash=sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127 \ + --hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \ + --hash=sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa \ + --hash=sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05 \ + --hash=sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171 \ + --hash=sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba \ + --hash=sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c \ + --hash=sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223 \ + --hash=sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4 \ + --hash=sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885 \ + --hash=sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698 \ + --hash=sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f \ + --hash=sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7 \ + --hash=sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed \ + --hash=sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f \ + --hash=sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf \ + --hash=sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e \ + --hash=sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f \ + --hash=sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24 \ + --hash=sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a \ + --hash=sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41 \ + --hash=sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc \ + --hash=sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d \ + --hash=sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146 \ + --hash=sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e \ + --hash=sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e \ + --hash=sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4 \ + --hash=sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12 \ + --hash=sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7 \ + --hash=sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261 \ + --hash=sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6 \ + --hash=sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5 \ + --hash=sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93 \ + --hash=sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7 \ + --hash=sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda \ + --hash=sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8 \ + --hash=sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342 \ + --hash=sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c \ + --hash=sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb \ + --hash=sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0 \ + --hash=sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77 \ + --hash=sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3 \ + --hash=sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885 \ + --hash=sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826 \ + --hash=sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617 \ + --hash=sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb \ + --hash=sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577 \ + --hash=sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80 \ + --hash=sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e \ + --hash=sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945 \ + --hash=sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90 \ + --hash=sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7 \ + --hash=sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0 \ + --hash=sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140 \ + --hash=sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822 \ + --hash=sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba \ + --hash=sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9 \ + --hash=sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4 \ + --hash=sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a \ + --hash=sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8 \ + --hash=sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf \ + --hash=sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4 \ + --hash=sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324 \ + --hash=sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53 \ + --hash=sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b \ + --hash=sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41 \ + --hash=sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9 \ + --hash=sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca \ + --hash=sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1 \ + --hash=sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d \ + --hash=sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690 \ + --hash=sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107 \ + --hash=sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2 \ + --hash=sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76 \ + --hash=sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d \ + --hash=sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af \ + --hash=sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6 \ + --hash=sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db \ + --hash=sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369 \ + --hash=sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd \ + --hash=sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911 \ + --hash=sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504 \ + --hash=sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a \ + --hash=sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9 \ + --hash=sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13 \ + --hash=sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc \ + --hash=sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278 \ + --hash=sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868 \ + --hash=sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2 \ + --hash=sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd \ + --hash=sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4 \ + --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6 \ + --hash=sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9 \ + --hash=sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00 \ + --hash=sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f \ + --hash=sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e \ + --hash=sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442 \ + --hash=sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da \ + --hash=sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90 \ + --hash=sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef + # via + # jsonschema + # referencing +securesystemslib==1.4.0 \ + --hash=sha256:a0743a3d978cf26e98a70a57e3fbd5a18e0a74c20cabe615f6a55b02ef0272b3 \ + --hash=sha256:faea87be0f9c4b4277a5fa1b54bf9bfd807be9a94ab11be6c557dc8b75c43285 + # via tuf +sigstore==4.5.0 \ + --hash=sha256:020d3e07f622b2916bf453e66ff6ff0711e1fdc5ab69e8bd8902f71d9fcb316f \ + --hash=sha256:f045b207f2e12605cf775ec38e89c5eda625d71ffa7830477db65e47ec2bc8b2 + # via -r constraints/release-seal.in +sigstore-models==0.0.6 \ + --hash=sha256:5201a68f4d7d0f8bec1e2f4378eb646b084c52609a4e31db8c385095fff68b2e \ + --hash=sha256:c766c09470c2a7e8a4a333c893f07e2001c56a3ff1757b1a246119f53169a849 + # via sigstore +sigstore-rekor-types==0.0.18 \ + --hash=sha256:19aef25433218ebf9975a1e8b523cc84aaf3cd395ad39a30523b083ea7917ec5 \ + --hash=sha256:b62bf38c5b1a62bc0d7fe0ee51a0709e49311d137c7880c329882a8f4b2d1d78 + # via sigstore +six==1.17.0 \ + --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ + --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 + # via + # python-dateutil + # rfc3339-validator +sortedcontainers==2.4.0 \ + --hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \ + --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 + # via cyclonedx-python-lib +trove-classifiers==2026.6.1.19 \ + --hash=sha256:ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3 \ + --hash=sha256:c5132b4b61a829d11cfbd2d72e97f20a45ed6edb95e45c5efdeb5e00836b2745 + # via hatchling +tuf==7.0.0 \ + --hash=sha256:572bdbdc9ff4a82278a0d4773e6100863b9b33023f27575e84ca65b486dd0d79 \ + --hash=sha256:9d2e6723538e0d5a3e482b6de805fcfe64481448d5853039ba6b06ba541efd7f + # via sigstore +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # cyclonedx-python-lib + # pydantic + # pydantic-core + # pyopenssl + # referencing + # sigstore-models + # typing-inspection +typing-inspection==0.4.2 \ + --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ + --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 + # via pydantic +tzdata==2026.3 \ + --hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \ + --hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931 + # via arrow +uri-template==1.3.0 \ + --hash=sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7 \ + --hash=sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363 + # via jsonschema +urllib3==2.7.0 \ + --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ + --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 + # via + # id + # requests + # tuf +webcolors==25.10.0 \ + --hash=sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d \ + --hash=sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf + # via jsonschema diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 1634bc29..57599f02 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -58,10 +58,12 @@ then PyPI already holds the bytes for source A, and a tag moved to B would make GitHub's source archives resolve to different code than the index serves. The draft is created with `gh release create --verify-tag`. -**Repository prerequisite:** protect `v*` tags with a ruleset that forbids -updates and deletions. The re-peel checks detect a moved tag and fail closed, -but detection is a weaker control than prevention, and they cannot help during -the window between GitHub resolving a ref and acting on it. +The tag is re-peeled again inside the upload step itself, immediately before +`uv publish`, because everything between the previous check and the upload — +artifact download, digest verification, signing, the index query — is window. + +See [Deployment prerequisites](#deployment-prerequisites): tag protection is +what actually closes this, and the re-peels are detection, not prevention. ### Which artifact is authoritative @@ -250,6 +252,33 @@ Nothing outside the run changed: no tag deletion, no cleanup needed. Fix the cause on the branch, and either move the tag (only safe while nothing has been published for it) or cut a new version. +## Deployment prerequisites + +Some windows in this pipeline cannot be closed by code in this repository, and +the workflow does not pretend otherwise. Each item below is a **repository or +organisation setting**; without them the corresponding check is detection after +the fact rather than prevention. + +| Prerequisite | What it closes | Residual without it | +|---|---|---| +| Ruleset on `v*` forbidding tag **updates and deletions** | A tag moving between verification and any later step | The re-peels detect a moved tag, but only at the next checkpoint. Between the last peel and `uv publish`, a move publishes immutable candidate A while the public tag resolves to B | +| **Immutable releases** enabled | Post-publication mutation of release assets | A `contents: write` actor can replace assets after finalisation, and nothing in this workflow runs again to notice | +| **Restricted release-write authority** (few actors, protected environment) | Concurrent mutation during finalisation | Remote verification and undrafting are two API calls. Another writer can replace an asset or add one in between, and the undraft publishes the changed server-side set | +| Protected `.github/workflows/**` and `.github/release-trust-roots.json` (CODEOWNERS or ruleset) | Changes to the pipeline and its trust roots landing unreviewed | Workflow logic is candidate-controlled at the tag, so review is the control that makes it trustworthy | +| `pypi` environment reviewers, independent of the release initiator | Unattended publication | Approval becomes a formality | + +### The limit worth stating plainly + +The workflow that runs for a tag is **the workflow at that tag** — it is part of +the candidate. The publication job is hardened as far as this repository can +harden it: it holds `id-token: write` and nothing else, checks out no +repository code, installs only a hash-locked closure with `--require-hashes`, +and classifies the index with `curl` and `jq` rather than a fetched helper. +That removes candidate *code* from the token-bearing job. It does not make the +job's own YAML a separate trust root, and no arrangement of files in this +repository can. Branch/tag protection and review of `.github/**` are what +supply that boundary. + ## Required configuration Qualification configuration is split by trust level: **what authenticates the diff --git a/scripts/_release_support.py b/scripts/_release_support.py index 715b0256..afd24c5d 100644 --- a/scripts/_release_support.py +++ b/scripts/_release_support.py @@ -51,6 +51,35 @@ def canonicalize_name(name: str) -> str: return re.sub(r"[-_.]+", "-", name).lower() +def parse_wheel_filename(filename: str) -> tuple[str, str, str | None, frozenset[str]]: + """Return ``(distribution, version, build tag, compatibility tags)``. + + A stdlib stand-in for ``packaging.utils.parse_wheel_filename`` so the + sealing job needs no third-party import. Per PEP 427 a wheel name is + ``{distribution}-{version}(-{build})?-{python}-{abi}-{platform}.whl``, and + the compressed tag fields expand on ``.`` into their cross product. + """ + + if not filename.endswith(".whl"): + raise ReleaseError(f"Not a wheel filename: {filename}") + parts = filename[: -len(".whl")].split("-") + if len(parts) not in (5, 6): + raise ReleaseError(f"Unparsable wheel filename: {filename}") + distribution = canonicalize_name(parts[0]) + version = parts[1] + build = parts[2] if len(parts) == 6 else None + pythons, abis, platforms = parts[-3:] + tags = frozenset( + f"{python}-{abi}-{platform}" + for python in pythons.split(".") + for abi in abis.split(".") + for platform in platforms.split(".") + ) + if not version or not tags: + raise ReleaseError(f"Unparsable wheel filename: {filename}") + return distribution, version, build, tags + + def inspect_wheel(path: Path) -> tuple[str, str, str]: """Return canonical distribution name, version, and content digest.""" diff --git a/scripts/build-llms-full.py b/scripts/build-llms-full.py index 74c4d2a2..a383dc4c 100644 --- a/scripts/build-llms-full.py +++ b/scripts/build-llms-full.py @@ -63,10 +63,7 @@ def render(repo_root: Path = REPO_ROOT) -> str: def main() -> int: OUTPUT_PATH.write_text(render(), encoding="utf-8") - print( - f"Wrote {OUTPUT_PATH.relative_to(REPO_ROOT)} " - f"({OUTPUT_PATH.stat().st_size:,} bytes)" - ) + print(f"Wrote {OUTPUT_PATH.relative_to(REPO_ROOT)} ({OUTPUT_PATH.stat().st_size:,} bytes)") return 0 diff --git a/scripts/generate_schemas.py b/scripts/generate_schemas.py index 782dab06..044c59ea 100644 --- a/scripts/generate_schemas.py +++ b/scripts/generate_schemas.py @@ -1254,11 +1254,7 @@ def _postprocess_authorization_evaluation(schema: dict[str, Any]) -> None: }, { "if": { - "properties": { - "status": { - "enum": ["rejected", "not_requested", "not_applicable"] - } - } + "properties": {"status": {"enum": ["rejected", "not_requested", "not_applicable"]}} }, "then": {"properties": {"command": {"type": "null"}}}, }, @@ -1270,11 +1266,7 @@ def _postprocess_authorization_evaluation(schema: dict[str, Any]) -> None: }, }, { - "if": { - "properties": { - "status": {"enum": ["not_requested", "not_applicable"]} - } - }, + "if": {"properties": {"status": {"enum": ["not_requested", "not_applicable"]}}}, "then": { "properties": { **{field: {"type": "null"} for field in authority_fields}, diff --git a/scripts/github_action_annotations.py b/scripts/github_action_annotations.py index d58c2256..b21a27a3 100644 --- a/scripts/github_action_annotations.py +++ b/scripts/github_action_annotations.py @@ -72,20 +72,11 @@ def emit_github_annotations(payload: dict[str, Any]) -> None: def _escape_data(value: object) -> str: - return ( - str(value) - .replace("%", "%25") - .replace("\r", "%0D") - .replace("\n", "%0A") - ) + return str(value).replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") def _escape_property(value: object) -> str: - return ( - _escape_data(value) - .replace(":", "%3A") - .replace(",", "%2C") - ) + return _escape_data(value).replace(":", "%3A").replace(",", "%2C") def _load_json(path: Path) -> dict[str, Any]: diff --git a/scripts/github_check_run.py b/scripts/github_check_run.py index 2bc62062..83b3cca7 100644 --- a/scripts/github_check_run.py +++ b/scripts/github_check_run.py @@ -41,9 +41,7 @@ PAYLOAD_FILENAME = "check-run-payload.json" DEFAULT_CHECK_NAME = "Agents Shipgate" DEFAULT_CHECK_RUN_POLICY = "advisory" -CHECK_RUN_POLICIES = frozenset( - {"advisory", "blocked-fails", "require-mergeable"} -) +CHECK_RUN_POLICIES = frozenset({"advisory", "blocked-fails", "require-mergeable"}) _CONCLUSIONS = { "mergeable": "success", @@ -136,9 +134,7 @@ def annotations_from_sarif(sarif: dict[str, Any] | None) -> list[dict[str, Any]] "path": path, "start_line": start_line, "end_line": start_line, - "annotation_level": _SARIF_LEVELS.get( - result.get("level"), "notice" - ), + "annotation_level": _SARIF_LEVELS.get(result.get("level"), "notice"), "message": message[:1000], "title": str(result.get("ruleId") or "agents-shipgate"), } @@ -248,9 +244,7 @@ def main() -> int: ) out_path = output_dir / PAYLOAD_FILENAME out_path.parent.mkdir(parents=True, exist_ok=True) - out_path.write_text( - json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" - ) + out_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") print(f"Wrote {out_path}") return 0 diff --git a/scripts/release_publication.py b/scripts/release_publication.py index 281b12d3..22750893 100644 --- a/scripts/release_publication.py +++ b/scripts/release_publication.py @@ -120,17 +120,23 @@ def build_manifest( def _assert_closed_world( - manifest_path: Path, base: Path, expected: set[str], allowed_extra: set[str] + manifest_path: Path, + base: Path, + expected: set[str], + allowed_extra: set[str], + required_extra: set[str], ) -> None: """Reject anything in the candidate directory the manifest does not name. - ``allowed_extra`` is an explicit allowlist, not an escape hatch: the only - legitimate additions are the signature bundles produced *after* the - manifest is sealed, and naming them individually keeps the check - closed-world. + ``allowed_extra`` permits a file; ``required_extra`` demands it. The + distinction matters at the end of the transaction: the signature bundles + are produced *after* the manifest is sealed, so they cannot be listed in + it, but a finished release that is missing one — or that carries arbitrary + bytes under the expected name — is not complete. Permitting without + requiring let exactly that pass. """ - allowed = expected | {manifest_path.name} | allowed_extra + allowed = expected | {manifest_path.name} | allowed_extra | required_extra present: set[str] = set() for entry in sorted(base.rglob("*")): if entry.is_dir(): @@ -146,6 +152,12 @@ def _assert_closed_world( "Candidate handoff contains files the manifest does not list " f"({', '.join(unexpected)}); publication would upload unverified bytes." ) + absent = sorted(required_extra - present) + if absent: + raise ReleaseError( + f"Release is missing required assets ({', '.join(absent)}); " + "the transaction is not complete." + ) def verify_manifest( @@ -154,6 +166,7 @@ def verify_manifest( expected_sha256: str | None = None, directory: Path | None = None, allowed_extra: set[str] | None = None, + required_extra: set[str] | None = None, ) -> dict[str, Any]: """Re-derive every digest the verification job recorded. @@ -204,7 +217,9 @@ def verify_manifest( if errors: raise ReleaseError("Candidate handoff rejected: " + "; ".join(errors)) - _assert_closed_world(manifest_path, base, listed, allowed_extra or set()) + _assert_closed_world( + manifest_path, base, listed, allowed_extra or set(), required_extra or set() + ) return manifest @@ -324,6 +339,17 @@ def _parser() -> argparse.ArgumentParser: "bundles produced after the manifest was sealed" ), ) + verify.add_argument( + "--require", + action="append", + default=[], + metavar="FILENAME", + help=( + "additionally require this filename to be present; use for the " + "final asset set, where a missing signature bundle means the " + "transaction did not complete" + ), + ) state = subparsers.add_parser("pypi-state", help="classify the index state for this wheel") state.add_argument("--wheel", type=Path, required=True) @@ -355,6 +381,7 @@ def main(argv: list[str] | None = None) -> int: expected_sha256=args.expected_sha256, directory=args.directory, allowed_extra=set(args.allow), + required_extra=set(args.require), ) sys.stdout.write( f"OK: all {len(manifest['assets'])} candidate assets match the verified digests.\n" diff --git a/scripts/run_benchmarks.py b/scripts/run_benchmarks.py index f088b33d..afc76fa1 100644 --- a/scripts/run_benchmarks.py +++ b/scripts/run_benchmarks.py @@ -282,8 +282,7 @@ def main() -> int: if args.scenario != "all": if args.scenario not in scenarios: sys.stderr.write( - f"unknown scenario {args.scenario!r}. " - f"Known: {sorted(scenarios.keys())}\n" + f"unknown scenario {args.scenario!r}. Known: {sorted(scenarios.keys())}\n" ) return 2 scenarios = {args.scenario: scenarios[args.scenario]} @@ -332,7 +331,9 @@ def main() -> int: if args.json is not None: args.json.parent.mkdir(parents=True, exist_ok=True) args.json.write_text(json.dumps(payload, indent=2), encoding="utf-8") - print(f" JSON written to {args.json.relative_to(_REPO_ROOT) if args.json.is_absolute() and args.json.is_relative_to(_REPO_ROOT) else args.json}") + print( + f" JSON written to {args.json.relative_to(_REPO_ROOT) if args.json.is_absolute() and args.json.is_relative_to(_REPO_ROOT) else args.json}" + ) if args.save: _RESULTS_ROOT.mkdir(parents=True, exist_ok=True) save_path = _RESULTS_ROOT / f"run-{int(started_at)}.json" diff --git a/scripts/verify_qualification_binding.py b/scripts/verify_qualification_binding.py new file mode 100644 index 00000000..00955138 --- /dev/null +++ b/scripts/verify_qualification_binding.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Re-derive the decisive qualification invariants without the project installed. + +`scripts/verify_safety_qualification_release.py` re-derives the *whole* policy — +every stratum, Wilson interval, and confusion matrix — but it imports the +project's pydantic schemas, which means the job running it has the editable +package and the ranged `dev` extra installed. That job is a fine place for a +thorough gate, and a poor place to seal a release: a compromised compatible +dependency there could rewrite the verifier itself. + +This module exists so the sealing job can restate the claims that actually +delegate publication authority, using nothing but the standard library: + +* the artifact is a **beta**, **qualified**, **production_qualified** result; +* it is static-only and does not claim runtime behaviour was proven; +* it carries no failures, exactly 100 cases, and 100 receipts; +* it reports **zero** unsafe auto-passes; +* and — the binding that matters — its recorded wheel name, version and + SHA-256 are the wheel about to be published. + +Signature verification stays outside: the release workflow runs +`sigstore verify identity` against the committed trust root *before* this file +is parsed, so what is checked here is a payload already proven to come from the +trusted signer. Together they mean a signed-but-weakened artifact cannot slip +through on the strength of its signature alone. + +Run from the repo root: + + python scripts/verify_qualification_binding.py \\ + --qualification qualified-dist/safety-qualification.json \\ + --wheel qualified-dist/agents_shipgate-0.16.0-py3-none-any.whl \\ + --tag v0.16.0 +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +if __package__: + from scripts._release_support import SHA256_PATTERN, ReleaseError, inspect_wheel +else: # ``python scripts/verify_qualification_binding.py`` + from _release_support import SHA256_PATTERN, ReleaseError, inspect_wheel + +REQUIRED_CASE_COUNT = 100 + + +def _require(errors: list[str], condition: bool, message: str) -> None: + if not condition: + errors.append(message) + + +def verify_qualification_binding( + *, qualification_path: Path, wheel_path: Path, tag: str +) -> dict[str, Any]: + """Fail closed unless the signed artifact qualifies exactly this wheel.""" + + if not qualification_path.is_file(): + raise ReleaseError(f"Safety qualification artifact not found: {qualification_path}") + try: + payload = json.loads(qualification_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ReleaseError(f"Invalid safety qualification artifact: {exc}") from exc + if not isinstance(payload, dict): + raise ReleaseError(f"{qualification_path} must contain a JSON object") + + wheel_name, wheel_version, wheel_sha256 = inspect_wheel(wheel_path) + inputs = payload.get("inputs") + summary = payload.get("summary") + cases = payload.get("cases") + errors: list[str] = [] + + _require(errors, isinstance(inputs, dict), "artifact has no inputs object") + _require(errors, isinstance(summary, dict), "artifact has no summary object") + _require(errors, isinstance(cases, list), "artifact has no cases array") + if errors: + raise ReleaseError(f"{qualification_path} is malformed: " + "; ".join(errors)) + + assert isinstance(inputs, dict) and isinstance(summary, dict) and isinstance(cases, list) + + _require(errors, payload.get("qualification_tier") == "beta", "qualification tier is not beta") + _require(errors, payload.get("qualified") is True, "artifact is not qualified") + _require( + errors, payload.get("production_qualified") is True, "artifact is not production_qualified" + ) + _require(errors, payload.get("static_only") is True, "artifact is not static-only") + _require( + errors, + payload.get("runtime_behavior_proven") is False, + "artifact claims runtime behaviour was proven", + ) + _require(errors, payload.get("failures") == [], "artifact reports failures") + + _require( + errors, + len(cases) == REQUIRED_CASE_COUNT, + f"artifact carries {len(cases)} cases, not {REQUIRED_CASE_COUNT}", + ) + _require( + errors, + summary.get("total_cases") == REQUIRED_CASE_COUNT, + "summary total_cases is not 100", + ) + _require( + errors, + summary.get("receipt_count") == REQUIRED_CASE_COUNT, + "summary receipt_count is not 100", + ) + _require(errors, summary.get("unsafe_auto_pass_count") == 0, "artifact reports an unsafe pass") + _require(errors, summary.get("runtime_failure_count") == 0, "artifact reports runtime failures") + + # The binding. Everything above is a claim; this is what ties the claim to + # the bytes that will reach the index. + _require(errors, inputs.get("wheel_name") == wheel_name, "qualified wheel name mismatch") + _require( + errors, inputs.get("wheel_version") == wheel_version, "qualified wheel version mismatch" + ) + _require( + errors, inputs.get("engine_version") == wheel_version, "qualified engine version mismatch" + ) + recorded_digest = str(inputs.get("wheel_sha256", "")) + _require( + errors, + bool(SHA256_PATTERN.fullmatch(recorded_digest)), + "qualified wheel SHA-256 is malformed", + ) + _require(errors, recorded_digest == wheel_sha256, "qualified wheel SHA-256 mismatch") + _require(errors, tag == f"v{wheel_version}", "release tag does not match wheel version") + + if errors: + raise ReleaseError( + "Release safety qualification rejected: " + "; ".join(sorted(set(errors))) + ) + return { + "qualification_tier": payload.get("qualification_tier"), + "wheel_name": wheel_name, + "wheel_version": wheel_version, + "wheel_sha256": wheel_sha256, + } + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Re-derive the decisive qualification invariants and wheel binding." + ) + parser.add_argument("--qualification", type=Path, required=True) + parser.add_argument("--wheel", type=Path, required=True) + parser.add_argument("--tag", required=True) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + record = verify_qualification_binding( + qualification_path=args.qualification, wheel_path=args.wheel, tag=args.tag + ) + except (ReleaseError, OSError, ValueError) as exc: + sys.stderr.write(f"Qualification binding error: {exc}\n") + return 1 + sys.stdout.write( + f"OK: signed {record['qualification_tier']} qualification is bound to " + f"{record['wheel_name']} {record['wheel_version']} ({record['wheel_sha256']}).\n" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_wheel_provenance.py b/scripts/verify_wheel_provenance.py index 4f8b85d1..b83d50c8 100644 --- a/scripts/verify_wheel_provenance.py +++ b/scripts/verify_wheel_provenance.py @@ -51,9 +51,12 @@ from pathlib import Path from typing import Literal -from packaging.utils import InvalidWheelFilename, parse_wheel_filename - -from agents_shipgate.core.errors import ConfigError +if __package__: + from scripts._release_support import ReleaseError as ConfigError + from scripts._release_support import parse_wheel_filename +else: # ``python scripts/verify_wheel_provenance.py`` + from _release_support import ReleaseError as ConfigError + from _release_support import parse_wheel_filename ProvenanceMode = Literal["identical_bytes", "identical_payload", "mismatch"] @@ -134,14 +137,10 @@ def _assert_compatible_filenames(built_path: Path, qualified_path: Path) -> None while hashing identically. """ - try: - built = parse_wheel_filename(built_path.name) - qualified = parse_wheel_filename(qualified_path.name) - except InvalidWheelFilename as exc: - raise ConfigError(f"Unparsable wheel filename: {exc}") from exc - - built_name, built_version, built_build, built_tags = built - qualified_name, qualified_version, qualified_build, qualified_tags = qualified + built_name, built_version, built_build, built_tags = parse_wheel_filename(built_path.name) + qualified_name, qualified_version, qualified_build, qualified_tags = parse_wheel_filename( + qualified_path.name + ) differences: list[str] = [] if built_name != qualified_name: @@ -151,10 +150,7 @@ def _assert_compatible_filenames(built_path: Path, qualified_path: Path) -> None if built_build != qualified_build: differences.append(f"build tag {built_build} vs {qualified_build}") if built_tags != qualified_tags: - differences.append( - f"compatibility tags {sorted(map(str, built_tags))} vs " - f"{sorted(map(str, qualified_tags))}" - ) + differences.append(f"compatibility tags {sorted(built_tags)} vs {sorted(qualified_tags)}") if differences: raise ConfigError( "Built and qualified wheel filenames describe different artifacts: " diff --git a/tests/test_release_pipeline.py b/tests/test_release_pipeline.py index 40073b7a..692bad33 100644 --- a/tests/test_release_pipeline.py +++ b/tests/test_release_pipeline.py @@ -24,7 +24,6 @@ import pytest import yaml -from agents_shipgate.core.errors import ConfigError from scripts._release_support import ReleaseError from scripts.release_publication import pypi_state, verify_manifest from scripts.release_sbom import dev_only_distributions, verify_release_sbom @@ -104,12 +103,12 @@ def test_qualified_wheel_carrying_extra_code_fails_closed(tmp_path: Path) -> Non built, qualified = _wheel_pair(tmp_path, {"agents_shipgate/_backdoor.py": "import os\n"}) - with pytest.raises(ConfigError) as excinfo: + with pytest.raises(ReleaseError) as excinfo: verify_wheel_provenance(built_path=built, qualified_path=qualified) assert "_backdoor.py" in str(excinfo.value) # Even the explicitly weaker interim bar must not let this through. - with pytest.raises(ConfigError): + with pytest.raises(ReleaseError): verify_wheel_provenance( built_path=built, qualified_path=qualified, allow_payload_equivalent=True ) @@ -118,7 +117,7 @@ def test_qualified_wheel_carrying_extra_code_fails_closed(tmp_path: Path) -> Non def test_modified_module_contents_fail_closed(tmp_path: Path) -> None: built, qualified = _wheel_pair(tmp_path, {"agents_shipgate/__init__.py": "x = 666\n"}) - with pytest.raises(ConfigError, match="content differs"): + with pytest.raises(ReleaseError, match="content differs"): verify_wheel_provenance(built_path=built, qualified_path=qualified) @@ -135,7 +134,7 @@ def test_container_only_difference_is_rejected_unless_explicitly_allowed(tmp_pat assert mode == "identical_payload" assert differences == [] - with pytest.raises(ConfigError, match="not byte-identical"): + with pytest.raises(ReleaseError, match="not byte-identical"): verify_wheel_provenance(built_path=built, qualified_path=qualified) record = verify_wheel_provenance( @@ -162,7 +161,7 @@ def test_identical_bytes_under_a_different_filename_are_rejected( built = _write_wheel(tmp_path / "built" / WHEEL_FILENAME) qualified = _write_wheel(tmp_path / "qualified" / renamed) - with pytest.raises(ConfigError, match=expected): + with pytest.raises(ReleaseError, match=expected): verify_wheel_provenance(built_path=built, qualified_path=qualified) @@ -473,15 +472,18 @@ def test_index_state_is_reclassified_inside_the_publish_attempt() -> None: failure retry an immutable version and never reach recovery.""" publish = _load_workflow("release.yml")["jobs"]["publish"] - upload = publish["steps"][_step_index(publish, "uv publish")] + upload = publish["steps"][_step_index(publish, "uv publish --trusted-publishing")] - # The decision is taken inside the step, from a fresh query. - assert "release_publication.py pypi-state" in upload["run"] + # The decision is taken inside the step, from a fresh query — and made + # with runner-provided tools, never a helper fetched from the candidate + # tree, because this job can mint a PyPI token. assert "if" not in upload - assert "is absent" in upload["run"] - assert "is published_identical" in upload["run"] - # Any other state stops rather than guessing. - assert "Unexpected index state before upload" in upload["run"] + assert "https://pypi.org/pypi/agents-shipgate/" in upload["run"] + assert "jq -r" in upload["run"] + assert "published_identical" in upload["run"] + assert "published_divergent" in upload["run"] + # An unreachable index is not permission to upload. + assert "not permission to upload" in upload["run"] def test_release_assets_are_uploaded_from_an_explicit_allowlist() -> None: @@ -932,10 +934,20 @@ def test_the_handoff_is_sealed_by_a_job_that_runs_no_candidate_tests() -> None: # The sealing job runs no suite, no plugins, no audit. assert "pytest" not in commands assert "pip_audit" not in commands - # And the suite job never touches the qualified wheel. + # And installs no editable project and no ranged dev extra: an unlocked + # resolve here could rewrite the verifier and the digests it seals. + assert "pip install -e" not in commands + assert '".[dev]"' not in commands + assert "--require-hashes" in commands + assert "constraints/release-seal.txt" in commands + # The suite job runs the exhaustive policy gate, so it downloads its own + # copy — into a different directory the sealer never reads. It is a gate, + # not a producer: nothing the sealer trusts comes out of it. tests_commands = _job_commands(workflow["jobs"]["tests"]) - assert "QUALIFIED_WHEEL" not in tests_commands + assert "policy-dist" in tests_commands + assert "qualified-dist" not in tests_commands assert "verify_wheel_provenance" not in tests_commands + assert "release_publication.py manifest" not in tests_commands def test_the_binding_is_reasserted_on_the_exact_bytes_being_sealed() -> None: @@ -984,8 +996,9 @@ def test_finalisation_verifies_remote_bytes_not_asset_names() -> None: assert "gh release download" in commands assert "verify-manifest" in commands assert '--expected-sha256 "${MANIFEST_SHA256}"' in commands - assert '--allow "${WHEEL_FILENAME}.sigstore.json"' in commands - assert "--allow agents-shipgate-sbom.json.sigstore.json" in commands + # `--require`, not `--allow`: a release missing a bundle is incomplete. + assert '--require "${WHEEL_FILENAME}.sigstore.json"' in commands + assert "--require agents-shipgate-sbom.json.sigstore.json" in commands # The signature bundles are verified, not merely present. assert "sigstore verify identity" in commands assert _step_index(finalize, "sigstore verify identity") < _step_index( @@ -1028,3 +1041,164 @@ def test_finalisation_runs_no_project_code_either() -> None: assert "--require-hashes" in commands # Uses the stdlib-only scripts fetched by immutable SHA. assert "tools/release_publication.py" in commands + + +# -------------------------------------------------------------------------- +# Review round 3 — the sealer's trust boundary +# -------------------------------------------------------------------------- + + +def test_the_sealer_installs_only_a_hash_locked_toolchain() -> None: + """An unlocked `pip install -e ".[dev]"` in the sealing job resolves dozens + of packages by range and runs before the build, the qualification checks, + the provenance comparison and the sealing — so one compromised compatible + release could rewrite the verifier and the digests it seals.""" + + lockfile = (REPO_ROOT / "constraints/release-seal.txt").read_text(encoding="utf-8") + + for pinned in ("build==", "hatchling==", "sigstore==", "cyclonedx-bom=="): + assert pinned in lockfile + assert lockfile.count("--hash=sha256:") > 20 + assert ">=" not in lockfile.replace("# ", "") + + +def test_the_sealer_and_the_build_pin_agree_on_the_backend() -> None: + """A backend mismatch between the two lockfiles would break byte equality + on a legitimate release.""" + + def _hatchling(path: str) -> str: + for line in (REPO_ROOT / path).read_text(encoding="utf-8").splitlines(): + if line.startswith("hatchling=="): + return line.split()[0] + raise AssertionError(f"no hatchling pin in {path}") + + assert _hatchling("constraints/release-build.txt") == _hatchling("constraints/release-seal.txt") + + +def test_the_sealer_restates_the_decisive_invariants_without_the_project() -> None: + """The exhaustive re-derivation needs pydantic, so it runs in the gate job. + The sealer must still restate the claims that delegate publication + authority, or a signed-but-weakened artifact passes on its signature.""" + + workflow = _load_workflow("release-verify.yml") + sealer = _job_commands(workflow["jobs"]["artifact"]) + gate = _job_commands(workflow["jobs"]["tests"]) + + assert "verify_qualification_binding.py" in sealer + assert "verify_safety_qualification_release.py" not in sealer + # The exhaustive version still runs, as a gate. + assert "verify_safety_qualification_release.py" in gate + + +def test_the_stdlib_invariant_checker_rejects_a_weakened_signed_artifact( + tmp_path: Path, +) -> None: + from scripts.verify_qualification_binding import verify_qualification_binding + + wheel = _write_wheel(tmp_path / WHEEL_FILENAME) + digest = _digest(wheel) + + def _artifact(**overrides: Any) -> Path: + payload: dict[str, Any] = { + "qualification_tier": "beta", + "qualified": True, + "production_qualified": True, + "static_only": True, + "runtime_behavior_proven": False, + "failures": [], + "cases": [{"id": f"c{i}"} for i in range(100)], + "summary": { + "total_cases": 100, + "receipt_count": 100, + "unsafe_auto_pass_count": 0, + "runtime_failure_count": 0, + }, + "inputs": { + "wheel_name": "agents-shipgate", + "wheel_version": "9.9.9", + "engine_version": "9.9.9", + "wheel_sha256": digest, + }, + } + payload.update(overrides) + path = tmp_path / "qualification.json" + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + # The honest artifact passes. + assert verify_qualification_binding( + qualification_path=_artifact(), wheel_path=wheel, tag="v9.9.9" + ) + + for overrides, expected in [ + ({"qualification_tier": "test"}, "tier is not beta"), + ({"production_qualified": False}, "not production_qualified"), + ({"cases": [{"id": "c0"}]}, "cases, not 100"), + ({"runtime_behavior_proven": True}, "runtime behaviour"), + ({"failures": ["x"]}, "reports failures"), + ]: + with pytest.raises(ReleaseError, match=expected): + verify_qualification_binding( + qualification_path=_artifact(**overrides), wheel_path=wheel, tag="v9.9.9" + ) + + # And the binding itself: a different wheel is rejected even when every + # policy claim is intact. + other = _write_wheel(tmp_path / "other" / WHEEL_FILENAME, {"agents_shipgate/_x.py": "y = 1\n"}) + with pytest.raises(ReleaseError, match="SHA-256 mismatch"): + verify_qualification_binding(qualification_path=_artifact(), wheel_path=other, tag="v9.9.9") + + +def test_the_suite_and_the_sealer_must_agree_on_the_commit() -> None: + """They check out independently; a mutable ref could test A and seal B.""" + + workflow = _load_workflow("release-verify.yml") + sealer = _job_commands(workflow["jobs"]["artifact"]) + + assert workflow["jobs"]["tests"]["outputs"]["source_sha"] + assert "The suite ran against ${TESTED_SHA}" in sealer + + +def test_the_rehearsal_cannot_pass_a_mutable_ref() -> None: + rehearsal = _load_workflow("release-rehearsal.yml") + + assert rehearsal["jobs"]["rehearse"]["with"]["ref"] == "${{ github.sha }}" + # No free-form ref input to resolve twice. + assert "ref" not in rehearsal["on"]["workflow_dispatch"]["inputs"] + + +def test_the_tag_is_rebound_inside_the_upload_step_itself() -> None: + """Artifact download, digest verification, signing and the index query all + sit between the previous peel and the irreversible upload.""" + + publish = _load_workflow("release.yml")["jobs"]["publish"] + upload = publish["steps"][_step_index(publish, "uv publish --trusted-publishing")]["run"] + + assert "git ls-remote" in upload + assert upload.index("git ls-remote") < upload.index("uv publish --trusted-publishing") + assert "refusing to publish" in upload + + +def test_an_already_published_release_must_be_complete_and_signed() -> None: + """Declaring the transaction complete makes both signature-verifying jobs + skip, so `--allow` (permit) was the wrong verb — the bundles must be + required and verified here.""" + + stage = _job_commands(_load_workflow("release.yml")["jobs"]["stage"]) + + assert '--require "${WHEEL_FILENAME}.sigstore.json"' in stage + assert "--require agents-shipgate-sbom.json.sigstore.json" in stage + assert "sigstore verify identity" in stage + + +def test_deployment_prerequisites_are_documented_with_their_residuals() -> None: + """Two of the windows cannot be closed by code in this repository, and the + docs must say so rather than implying the workflow handles them.""" + + runbook = (REPO_ROOT / "docs/release-runbook.md").read_text(encoding="utf-8") + + assert "## Deployment prerequisites" in runbook + for prerequisite in ("Immutable releases", "updates and deletions", "release-write"): + assert prerequisite in runbook + # The honest limit about the workflow being candidate-controlled. + assert "the workflow at that tag" in runbook diff --git a/tests/test_safety_qualification_release.py b/tests/test_safety_qualification_release.py index bbe8fed6..dd651ae1 100644 --- a/tests/test_safety_qualification_release.py +++ b/tests/test_safety_qualification_release.py @@ -311,10 +311,20 @@ def test_release_workflow_reuses_signed_qualified_wheel_before_publish() -> None verify = (REPO_ROOT / ".github/workflows/release-verify.yml").read_text(encoding="utf-8") release = (REPO_ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") - signature_index = verify.index("sigstore verify identity") - binding_index = verify.index("scripts/verify_safety_qualification_release.py") - provenance_index = verify.index("scripts/verify_wheel_provenance.py") - assert signature_index < binding_index < provenance_index + # Ordering is asserted inside the sealing job. The exhaustive policy + # re-derivation lives in the `tests` gate because it needs the project + # installed; the sealer verifies the signature first, then restates the + # decisive invariants, then binds the wheel to the tagged source. + sealer = yaml.safe_load(verify)["jobs"]["artifact"]["steps"] + order = [json.dumps(step) for step in sealer] + + def _at(needle: str) -> int: + return next(i for i, step in enumerate(order) if needle in step) + + assert _at("sigstore verify identity") < _at("scripts/verify_qualification_binding.py") + assert _at("scripts/verify_qualification_binding.py") < _at( + "scripts/verify_wheel_provenance.py" + ) assert "SAFETY_QUALIFICATION_WHEEL_URL" in verify assert "SAFETY_QUALIFICATION_JSON_URL" in verify assert "SAFETY_QUALIFICATION_SIGSTORE_BUNDLE_URL" in verify From d36939777bc80add54e295f156cd8eafdebb3310 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Mon, 10 Aug 2026 10:34:27 -0700 Subject: [PATCH 7/8] fix(release): stop executing dependency code in the sealer, and fix draft/latest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 review. The first finding is arbitrary code execution and I reproduced it before fixing it. `cyclonedx-py environment` inventories by *launching* the target interpreter, and interpreter startup runs `site` processing, which executes any `.pth` file beginning with `import`. Those files come from the wheel's runtime closure, resolved unpinned from the index. A `.pth` dropped into that environment ran inside the job that seals the release, before the handoff digests were computed — proven with a marker file that the old path wrote and the new one does not. `scripts/release_sbom.py` now inventories installed `.dist-info` metadata with the standard library and never starts that interpreter; installation is `--only-binary :all:` so an sdist cannot run a build backend either. CycloneDX left the sealer's closure entirely as a result. Draft releases cannot be marked latest: GitHub rejects `draft: true` with `make_latest: true`, so the create step would have failed every first stable release during staging. Drafts are now created with `--latest=false` (or `--prerelease`), and maturity is applied only when the draft is lifted. `python -m build` defaulted to isolation, creating a fresh environment and re-resolving the pyproject build requirements — so only hatchling was pinned and its transitive build dependencies escaped the hash-locked closure. Now `--no-isolation`, verified byte-equivalent to an isolated build. The exhaustive policy gate and the sealer download from the same mutable URLs at different times, so the gate could prove a policy about one artifact while the sealer sealed another. The gate now publishes the digests of exactly what it accepted and the sealer binds its own downloads to them. The stdlib binder also derives unsafe-pass, runtime-failure and receipt counts from the cases instead of trusting the summary the artifact writes about itself, and requires the summary to agree with them. `--require-hashes` constrains integrity, not choice: the lockfile comes from the candidate commit, so nothing in it stopped a candidate adding a package to the job that can mint a PyPI token. An allowlist of permitted distributions now lives in the workflow, so widening the set is a reviewed `.github/**` diff rather than a line in a generated file. This raises the review floor; it is not an independent trust root, and the runbook says so. Co-Authored-By: Claude Opus 5 --- .github/workflows/release-verify.yml | 34 +- .github/workflows/release.yml | 52 ++- constraints/release-seal.in | 1 - constraints/release-seal.txt | 419 ++---------------------- docs/release-runbook.md | 8 + scripts/release_sbom.py | 224 +++++++------ scripts/verify_qualification_binding.py | 50 ++- tests/test_release_pipeline.py | 208 +++++++++++- 8 files changed, 490 insertions(+), 506 deletions(-) diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index 9095c0cc..ba652dfd 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -75,6 +75,12 @@ jobs: contents: read outputs: source_sha: ${{ steps.tested.outputs.source_sha }} + # Digests of the exact inputs the exhaustive policy gate accepted. The + # sealer re-downloads from the same (mutable) URLs and must land on these + # bytes, or a validly signed but weakened artifact could be sealed + # without ever passing the gate. + qualified_wheel_sha256: ${{ steps.policy.outputs.qualified_wheel_sha256 }} + qualification_sha256: ${{ steps.policy.outputs.qualification_sha256 }} steps: - name: Checkout @@ -134,6 +140,7 @@ jobs: run: python -m pip_audit . - name: Exhaustive safety qualification policy re-derivation + id: policy # Re-derives every stratum, Wilson interval and confusion matrix in the # signed artifact. It imports the project's pydantic schemas, so it # belongs here, with the project installed, rather than in the sealing @@ -159,6 +166,10 @@ jobs: --qualification policy-dist/safety-qualification.json \ --tag "${tag}" python -m twine check "policy-dist/${QUALIFIED_WHEEL_FILENAME}" + { + echo "qualified_wheel_sha256=$(sha256sum "policy-dist/${QUALIFIED_WHEEL_FILENAME}" | cut -d' ' -f1)" + echo "qualification_sha256=$(sha256sum policy-dist/safety-qualification.json | cut -d' ' -f1)" + } >> "${GITHUB_OUTPUT}" artifact: name: Verify and seal the candidate @@ -349,7 +360,14 @@ jobs: echo "::error::source-build must not pre-exist in the release checkout." exit 1 fi - python -m build --wheel --outdir source-build . + # `--no-isolation` so the hash-locked hatchling installed above is + # the backend that actually runs. With isolation, `build` creates a + # fresh environment and re-resolves the `pyproject.toml` build + # requirements, which puts the backend's own transitive dependencies + # outside the lock — the locked closure would be bypassed for exactly + # the code that produces the artifact. Verified byte-equivalent to an + # isolated build. + python -m build --wheel --no-isolation --outdir source-build . - name: Download configured qualification inputs id: qualified @@ -378,6 +396,20 @@ jobs: echo "QUALIFIED_WHEEL_FILENAME=${QUALIFIED_WHEEL_FILENAME}" >> "${GITHUB_ENV}" echo "wheel_filename=${QUALIFIED_WHEEL_FILENAME}" >> "${GITHUB_OUTPUT}" + - name: Bind the sealed inputs to the exhaustively verified ones + # The gate and this job download from the same mutable URLs at + # different times. Without this comparison the gate proves a policy + # about one set of bytes while the sealer seals another. + env: + GATE_WHEEL_SHA256: ${{ needs.tests.outputs.qualified_wheel_sha256 }} + GATE_QUALIFICATION_SHA256: ${{ needs.tests.outputs.qualification_sha256 }} + run: | + set -euo pipefail + echo "${GATE_WHEEL_SHA256} ${QUALIFIED_WHEEL}" | sha256sum --check --strict + echo "${GATE_QUALIFICATION_SHA256} qualified-dist/safety-qualification.json" \ + | sha256sum --check --strict + echo "OK: sealing the exact inputs the exhaustive policy gate accepted." + - name: Verify qualification signature against the committed trust root env: QUALIFICATION_SIGNER_IDENTITY: ${{ steps.trust_roots.outputs.signer_identity }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec3b1aff..40dc9c77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -170,12 +170,18 @@ jobs: set -euo pipefail # `v1.2.3` is a final release; anything else (v0.16.0b7, rc, a) is a # pre-release and must not be promoted to "Latest". + # + # Maturity is applied only at finalisation. A draft cannot be marked + # latest — GitHub rejects `draft: true` together with + # `make_latest: true` — so requesting it at create time would fail + # every first-time stable release here, before PyPI is touched. The + # draft is created with the pre-release flag when applicable and no + # latest request at all. if [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - maturity="--latest" + create_maturity="--latest=false" else - maturity="--prerelease" + create_maturity="--prerelease" fi - echo "RELEASE_MATURITY=${maturity}" >> "${GITHUB_ENV}" assets=( "dist/${WHEEL_FILENAME}" @@ -190,7 +196,7 @@ jobs: gh release create "${RELEASE_TAG}" "${assets[@]}" \ --draft \ --verify-tag \ - "${maturity}" \ + "${create_maturity}" \ --title "${RELEASE_TAG}" \ --notes "Agents Shipgate ${RELEASE_TAG}" echo "release_state=absent" >> "${GITHUB_OUTPUT}" @@ -264,10 +270,6 @@ jobs: LOCKFILE_URL: https://raw.githubusercontent.com/${{ github.repository }}/${{ needs.verify.outputs.source_sha }}/constraints/release-publish.txt run: | set -euo pipefail - # Only the lockfile is fetched, and `--require-hashes` means a - # substituted lockfile cannot install anything: every artifact must - # match a recorded digest. - # # No candidate *code* runs in this job. An earlier revision fetched # `release_publication.py` here and ran it before `uv publish`; # "standard library only" is not a trust boundary, because the file @@ -276,6 +278,40 @@ jobs: # therefore uses curl and jq, which the runner provides. curl --fail --location --proto '=https' --proto-redir '=https' \ --output release-publish.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. + # + # The allowlist below is the constraint on choice. It lives in this + # workflow rather than in the lockfile, so widening the set means + # editing `.github/**` — a small, reviewed diff — instead of adding a + # line to a 400-line generated file where it would pass unnoticed. + # This raises the review floor; it is not an independent trust root, + # because this workflow 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-publish.txt \ + | sed 's/==$//' | tr 'A-Z_.' 'a-z--' | sort -u > requested.txt + while read -r name; do + case " ${allowed} " in + *" ${name} "*) ;; + *) + echo "::error::Publication lockfile requests ${name}, which is not in this workflow's allowlist." + exit 1 + ;; + esac + done < requested.txt + echo "OK: every locked distribution is on the workflow allowlist." + python -m pip install --require-hashes --requirement release-publish.txt - name: Confirm the tag still points at the verified commit diff --git a/constraints/release-seal.in b/constraints/release-seal.in index 0ed65cfc..c658ebbd 100644 --- a/constraints/release-seal.in +++ b/constraints/release-seal.in @@ -1,4 +1,3 @@ build==1.5.0 hatchling==1.31.0 sigstore==4.5.0 -cyclonedx-bom==7.3.1 diff --git a/constraints/release-seal.txt b/constraints/release-seal.txt index 2e6181c0..2581e599 100644 --- a/constraints/release-seal.txt +++ b/constraints/release-seal.txt @@ -1,7 +1,7 @@ # Hash-locked toolchain for the sealing job. # -# The sealer decides what gets published: it compares the wheel it builds from -# the tagged source against the qualified wheel, re-derives the decisive +# The sealer decides what gets published: it builds the wheel from the tagged +# source, compares it against the qualified wheel, re-derives the decisive # qualification invariants, generates the SBOM, and seals the digests that # delegate publication authority to later jobs. # @@ -10,15 +10,22 @@ # single compromised compatible release could rewrite the verifier, both wheel # copies, and the digests themselves, and every downstream check would agree. # -# Only these four direct requirements, with their full transitive closure -# pinned and hashed, installed with `--require-hashes`: +# Three direct requirements, with their full transitive closure pinned and +# hashed, installed with `--require-hashes`: # -# build + hatchling build the wheel from the tagged source (hatchling is -# pinned separately in release-build.txt for byte -# reproducibility; the pin here must match it) +# build + hatchling build the wheel from the tagged source. The wheel is +# built with `--no-isolation` so *this* locked backend +# closure is the code that runs; with isolation, `build` +# would create a fresh environment and re-resolve the +# backend's own dependencies outside the lock. The +# hatchling pin must match constraints/release-build.txt, +# which is what makes the wheel byte-reproducible. # sigstore verify the qualification signature against the -# committed trust root -# cyclonedx-bom generate the wheel-scoped SBOM +# committed trust root. +# +# The SBOM needs no tooling: scripts/release_sbom.py inventories installed +# `.dist-info` metadata with the standard library, precisely so that it never +# launches the wheel's runtime environment and never executes a `.pth` from it. # # Regenerate with: # @@ -29,20 +36,6 @@ annotated-types==0.8.0 \ --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 # via pydantic -arrow==1.4.0 \ - --hash=sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205 \ - --hash=sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7 - # via isoduration -attrs==26.1.0 \ - --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ - --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 - # via - # jsonschema - # referencing -boolean-py==5.0 \ - --hash=sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95 \ - --hash=sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9 - # via license-expression build==1.5.0 \ --hash=sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f \ --hash=sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647 @@ -153,10 +146,6 @@ cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 # via cryptography -chardet==5.2.0 \ - --hash=sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7 \ - --hash=sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970 - # via cyclonedx-bom charset-normalizer==3.4.9 \ --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ --hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \ @@ -307,18 +296,6 @@ cryptography==50.0.0 \ # pyopenssl # rfc3161-client # sigstore -cyclonedx-bom==7.3.1 \ - --hash=sha256:8c5adccefd593e89c0500463649c232625cee8e38dae1a433e057dfa495eff08 \ - --hash=sha256:ebd99dc2cf62a7067e327bef2b2ffada99d57ed3fda2db6c4c8f3004e5bfd3cf - # via -r constraints/release-seal.in -cyclonedx-python-lib==11.11.0 \ - --hash=sha256:3049fc83e06a059b5c5907a527625a8ed5073caab10607ed4c9e5503b590fd44 \ - --hash=sha256:4b3194db72b613717f2912447e67ab618c75ff7dcac6c4af3c0e9e1ac617c102 - # via cyclonedx-bom -defusedxml==0.7.1 \ - --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \ - --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 - # via py-serializable dnspython==2.8.0 \ --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \ --hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f @@ -327,10 +304,6 @@ email-validator==2.3.0 \ --hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \ --hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426 # via pydantic -fqdn==1.5.1 \ - --hash=sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f \ - --hash=sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014 - # via jsonschema hatchling==1.31.0 \ --hash=sha256:6b48ad4068a482ed7239b3a8215bc55b47aad3345d58dfc94e553c5d2d46211b \ --hash=sha256:aac80bec8b6fe35e8480f1c335be8910fa210a0e6f735a139be205dadcacb544 @@ -344,168 +317,7 @@ idna==3.18 \ --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 # via # email-validator - # jsonschema # requests -isoduration==20.11.0 \ - --hash=sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9 \ - --hash=sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042 - # via jsonschema -jsonpointer==3.1.1 \ - --hash=sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900 \ - --hash=sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca - # via jsonschema -jsonschema==4.26.0 \ - --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ - --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce - # via cyclonedx-python-lib -jsonschema-specifications==2025.9.1 \ - --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ - --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d - # via jsonschema -lark==1.3.1 \ - --hash=sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905 \ - --hash=sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12 - # via rfc3987-syntax -license-expression==30.4.4 \ - --hash=sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4 \ - --hash=sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd - # via cyclonedx-python-lib -lxml==6.1.1 \ - --hash=sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2 \ - --hash=sha256:07a4a68e286ee7a1ed7dfb8af83e615757c0ccfe9f18c6b4ea6771388d9ba8c9 \ - --hash=sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60 \ - --hash=sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c \ - --hash=sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7 \ - --hash=sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a \ - --hash=sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83 \ - --hash=sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072 \ - --hash=sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8 \ - --hash=sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462 \ - --hash=sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0 \ - --hash=sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085 \ - --hash=sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f \ - --hash=sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30 \ - --hash=sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1 \ - --hash=sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77 \ - --hash=sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740 \ - --hash=sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b \ - --hash=sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c \ - --hash=sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621 \ - --hash=sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e \ - --hash=sha256:32ab449a5486f6c758e849bb86710d0e45edc24a04e250c01555f8f5653958f8 \ - --hash=sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca \ - --hash=sha256:34c2d737beabfe35baada43941ed519251e9a12e779031496bcd5d539fcfd730 \ - --hash=sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245 \ - --hash=sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1 \ - --hash=sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004 \ - --hash=sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d \ - --hash=sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52 \ - --hash=sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5 \ - --hash=sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf \ - --hash=sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc \ - --hash=sha256:441dd227fa0690eb9fc81edabc63cdcefc212bba99b906dcf6e32cc1a9d3e533 \ - --hash=sha256:469e3618338bd7ab5beb412d2439825479fcf0dab99e394ca563dbc4eaf6c834 \ - --hash=sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947 \ - --hash=sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a \ - --hash=sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2 \ - --hash=sha256:53c909b62a0532183542fed00c5a7218258c56292d409bc789886fe1cb04c438 \ - --hash=sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc \ - --hash=sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea \ - --hash=sha256:55b03549819867ea141c0202242c4816c82e52ec36e7e648db9d8da5a3dc3ed6 \ - --hash=sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e \ - --hash=sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c \ - --hash=sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383 \ - --hash=sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955 \ - --hash=sha256:5bec7d03d78d853597d6107854c2310ce3f761fd218fe9fe91d5101fcf6c2efe \ - --hash=sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074 \ - --hash=sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c \ - --hash=sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a \ - --hash=sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb \ - --hash=sha256:639f6c857d91d9be29bd7502348d6736dab168b54b5158cd899abf11684dc186 \ - --hash=sha256:640f97d43d867bcb9c75b3af013b64850756b746cb6bce8ace83b70da3abba9d \ - --hash=sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1 \ - --hash=sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f \ - --hash=sha256:6689e828a94eee4f139408c337bb198e014724bb8a8c26d3cfac49d119ed69a6 \ - --hash=sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736 \ - --hash=sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6 \ - --hash=sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2 \ - --hash=sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b \ - --hash=sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7 \ - --hash=sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14 \ - --hash=sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009 \ - --hash=sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca \ - --hash=sha256:76447f65250ed2501ead1a1552f5ce8edff159a86f308348e6a9c4acb5e1f1b4 \ - --hash=sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635 \ - --hash=sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee \ - --hash=sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e \ - --hash=sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9 \ - --hash=sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603 \ - --hash=sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08 \ - --hash=sha256:83b6b30eb131da7a75b601f28c5d6971e6ed3e887919bf6b6a1ad3c2df289080 \ - --hash=sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525 \ - --hash=sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5 \ - --hash=sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f \ - --hash=sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067 \ - --hash=sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e \ - --hash=sha256:8be8ad51249698103d24b0571df35a10990fbe93dd043b6c024172189485f5e3 \ - --hash=sha256:8d43ca737b20e106e4aebc42b2f3ae19f00ba63d7eb731698ee083d72d15646f \ - --hash=sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485 \ - --hash=sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13 \ - --hash=sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383 \ - --hash=sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315 \ - --hash=sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e \ - --hash=sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c \ - --hash=sha256:9f76acfb5f68ba982635a53fd985a8044be98a35b43232c2a1ee235ffab3e1dd \ - --hash=sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099 \ - --hash=sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660 \ - --hash=sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510 \ - --hash=sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a \ - --hash=sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b \ - --hash=sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5 \ - --hash=sha256:aae97dfdb60715c164419ac2532a76d013c3918a665eb6cb7288098b5f349aaf \ - --hash=sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28 \ - --hash=sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00 \ - --hash=sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef \ - --hash=sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1 \ - --hash=sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955 \ - --hash=sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590 \ - --hash=sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137 \ - --hash=sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf \ - --hash=sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40 \ - --hash=sha256:bdebcc8a75d38c7598dfb2c9ed852d7a9eb4a10d6e2d0764b919b802bf32ac88 \ - --hash=sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e \ - --hash=sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840 \ - --hash=sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2 \ - --hash=sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca \ - --hash=sha256:c674693f055fa2495de12292cb45e9944199d8eaef5a2dec45175c7c61cb73e3 \ - --hash=sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465 \ - --hash=sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc \ - --hash=sha256:c9a4b821dc7055bf9e05ff5719e18ec501f75c0f0bbfabd573b277559780833d \ - --hash=sha256:c9f79d5325907f13e1be0b3e4dacc1049d1dffc4aeee3c995284bea5fe0fab7d \ - --hash=sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa \ - --hash=sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a \ - --hash=sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a \ - --hash=sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206 \ - --hash=sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e \ - --hash=sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785 \ - --hash=sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8 \ - --hash=sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a \ - --hash=sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b \ - --hash=sha256:e07c65f443c887bbcf31cc1771d932ecc192a5273943589b3c7572b749f1ffb2 \ - --hash=sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6 \ - --hash=sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6 \ - --hash=sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354 \ - --hash=sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818 \ - --hash=sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84 \ - --hash=sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909 \ - --hash=sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038 \ - --hash=sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d \ - --hash=sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2 \ - --hash=sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf \ - --hash=sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc \ - --hash=sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d \ - --hash=sha256:ffecec8eb889b58ba9be5b95fb1cc78e22ea8eedea38e8736a1568fe1979250e - # via cyclonedx-python-lib markdown-it-py==4.2.0 \ --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a @@ -514,40 +326,24 @@ mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba # via markdown-it-py -packageurl-python==0.17.6 \ - --hash=sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25 \ - --hash=sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9 - # via - # cyclonedx-bom - # cyclonedx-python-lib packaging==26.3 \ --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c # via # build - # cyclonedx-bom # hatchling - # pip-requirements-parser pathspec==1.1.1 \ --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \ --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 # via hatchling -pip-requirements-parser==32.0.1 \ - --hash=sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526 \ - --hash=sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3 - # via cyclonedx-bom -platformdirs==4.11.1 \ - --hash=sha256:2efd27d363e8dd2e661639ffb398865a5e0a46442a11d266bf375a0e0c10e386 \ - --hash=sha256:bb1af68078f25e2f3e111e2d43b8d536df41b73c8a684b40bb018223b66fae27 +platformdirs==4.11.2 \ + --hash=sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d \ + --hash=sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4 # via sigstore pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 # via hatchling -py-serializable==2.1.0 \ - --hash=sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103 \ - --hash=sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304 - # via cyclonedx-python-lib pyasn1==0.6.4 \ --hash=sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81 \ --hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b @@ -697,25 +493,10 @@ pyopenssl==26.4.0 \ --hash=sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7 \ --hash=sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c # via sigstore -pyparsing==3.3.2 \ - --hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \ - --hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc - # via pip-requirements-parser pyproject-hooks==1.2.0 \ --hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \ --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 # via build -python-dateutil==2.9.0.post0 \ - --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ - --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 - # via arrow -referencing==0.37.0 \ - --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ - --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 - # via - # cyclonedx-python-lib - # jsonschema - # jsonschema-specifications requests==2.34.2 \ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed @@ -735,18 +516,6 @@ rfc3161-client==1.0.8 \ --hash=sha256:d3c25311c67a7daeef990fb5b94eaed706135c6a6fd98c6e382bbc857faa4214 \ --hash=sha256:e95ca8a64fddfdd639e09e48bab4722f31b26ce099067ad2bb8e85f88fcc707b # via sigstore -rfc3339-validator==0.1.4 \ - --hash=sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b \ - --hash=sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa - # via jsonschema -rfc3986-validator==0.1.1 \ - --hash=sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9 \ - --hash=sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055 - # via jsonschema -rfc3987-syntax==1.1.0 \ - --hash=sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f \ - --hash=sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d - # via jsonschema rfc8785==0.1.4 \ --hash=sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48 \ --hash=sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da @@ -755,126 +524,6 @@ rich==15.0.0 \ --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 # via sigstore -rpds-py==2026.6.3 \ - --hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \ - --hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \ - --hash=sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9 \ - --hash=sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538 \ - --hash=sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804 \ - --hash=sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf \ - --hash=sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4 \ - --hash=sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97 \ - --hash=sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6 \ - --hash=sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96 \ - --hash=sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a \ - --hash=sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187 \ - --hash=sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975 \ - --hash=sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f \ - --hash=sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703 \ - --hash=sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9 \ - --hash=sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127 \ - --hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \ - --hash=sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa \ - --hash=sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05 \ - --hash=sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171 \ - --hash=sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba \ - --hash=sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c \ - --hash=sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223 \ - --hash=sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4 \ - --hash=sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885 \ - --hash=sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698 \ - --hash=sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f \ - --hash=sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7 \ - --hash=sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed \ - --hash=sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f \ - --hash=sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf \ - --hash=sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e \ - --hash=sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f \ - --hash=sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24 \ - --hash=sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a \ - --hash=sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41 \ - --hash=sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc \ - --hash=sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d \ - --hash=sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146 \ - --hash=sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e \ - --hash=sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e \ - --hash=sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4 \ - --hash=sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12 \ - --hash=sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7 \ - --hash=sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261 \ - --hash=sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6 \ - --hash=sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5 \ - --hash=sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93 \ - --hash=sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7 \ - --hash=sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda \ - --hash=sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8 \ - --hash=sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342 \ - --hash=sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c \ - --hash=sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb \ - --hash=sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0 \ - --hash=sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77 \ - --hash=sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3 \ - --hash=sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885 \ - --hash=sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826 \ - --hash=sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617 \ - --hash=sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb \ - --hash=sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577 \ - --hash=sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80 \ - --hash=sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e \ - --hash=sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945 \ - --hash=sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90 \ - --hash=sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7 \ - --hash=sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0 \ - --hash=sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140 \ - --hash=sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822 \ - --hash=sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba \ - --hash=sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9 \ - --hash=sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4 \ - --hash=sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a \ - --hash=sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8 \ - --hash=sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf \ - --hash=sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4 \ - --hash=sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324 \ - --hash=sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53 \ - --hash=sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b \ - --hash=sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41 \ - --hash=sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9 \ - --hash=sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca \ - --hash=sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1 \ - --hash=sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d \ - --hash=sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690 \ - --hash=sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107 \ - --hash=sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2 \ - --hash=sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76 \ - --hash=sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d \ - --hash=sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af \ - --hash=sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6 \ - --hash=sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db \ - --hash=sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369 \ - --hash=sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd \ - --hash=sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911 \ - --hash=sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504 \ - --hash=sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a \ - --hash=sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9 \ - --hash=sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13 \ - --hash=sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc \ - --hash=sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278 \ - --hash=sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868 \ - --hash=sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2 \ - --hash=sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd \ - --hash=sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4 \ - --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6 \ - --hash=sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9 \ - --hash=sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00 \ - --hash=sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f \ - --hash=sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e \ - --hash=sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442 \ - --hash=sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da \ - --hash=sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90 \ - --hash=sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef - # via - # jsonschema - # referencing securesystemslib==1.4.0 \ --hash=sha256:a0743a3d978cf26e98a70a57e3fbd5a18e0a74c20cabe615f6a55b02ef0272b3 \ --hash=sha256:faea87be0f9c4b4277a5fa1b54bf9bfd807be9a94ab11be6c557dc8b75c43285 @@ -891,16 +540,6 @@ sigstore-rekor-types==0.0.18 \ --hash=sha256:19aef25433218ebf9975a1e8b523cc84aaf3cd395ad39a30523b083ea7917ec5 \ --hash=sha256:b62bf38c5b1a62bc0d7fe0ee51a0709e49311d137c7880c329882a8f4b2d1d78 # via sigstore -six==1.17.0 \ - --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ - --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 - # via - # python-dateutil - # rfc3339-validator -sortedcontainers==2.4.0 \ - --hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \ - --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 - # via cyclonedx-python-lib trove-classifiers==2026.6.1.19 \ --hash=sha256:ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3 \ --hash=sha256:c5132b4b61a829d11cfbd2d72e97f20a45ed6edb95e45c5efdeb5e00836b2745 @@ -913,25 +552,15 @@ typing-extensions==4.16.0 \ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 # via - # cyclonedx-python-lib # pydantic # pydantic-core # pyopenssl - # referencing # sigstore-models # typing-inspection -typing-inspection==0.4.2 \ - --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ - --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 +typing-inspection==0.4.3 \ + --hash=sha256:5f42b23858a91e0b4ef521f5418f03a0da3c9216fd2995ef5e73463100e676cd \ + --hash=sha256:c5f9ec1530b5c1e2c9bc34a84d9a3466ed1b2f3f2fa9f901368d9c5596210e4d # via pydantic -tzdata==2026.3 \ - --hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \ - --hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931 - # via arrow -uri-template==1.3.0 \ - --hash=sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7 \ - --hash=sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363 - # via jsonschema urllib3==2.7.0 \ --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 @@ -939,7 +568,3 @@ urllib3==2.7.0 \ # id # requests # tuf -webcolors==25.10.0 \ - --hash=sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d \ - --hash=sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf - # via jsonschema diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 57599f02..f960dcb1 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -19,6 +19,14 @@ A release runs as five jobs with an explicit, content-addressed handoff. | `publish` | `id-token: write`, `environment: pypi` | Signs and uploads to PyPI once | | `finalize` | `contents: write` | Attaches signatures, re-verifies the remote bytes, undrafts | +The sealing job installs no project code — only the hash-locked toolchain in +`constraints/release-seal.txt` — and executes nothing from the wheel's runtime +closure. The SBOM is produced by reading installed `.dist-info` metadata rather +than launching the environment's interpreter, because interpreter startup runs +`site` processing, which executes any `.pth` file the closure installed. The +wheel itself is built with `--no-isolation` so the locked backend is the code +that runs. + Verification is two jobs for a specific reason: **the job that seals the handoff never runs the candidate's tests.** In a combined job the qualified wheel stayed writable on disk — with its path exported through `GITHUB_ENV` — while pytest, diff --git a/scripts/release_sbom.py b/scripts/release_sbom.py index c453ef03..7e94af89 100644 --- a/scripts/release_sbom.py +++ b/scripts/release_sbom.py @@ -10,17 +10,18 @@ ``build`` installs *only* the wheel into an isolated interpreter created with ``--without-pip``, so the resulting environment is exactly the wheel plus its -runtime closure, then inventories that. Two post-processing steps matter: - -1. CycloneDX records the wheel it installed from as an ``externalReferences`` - entry of type ``distribution``, including a ``file://`` URL pointing at the - build machine's temporary directory. That path varies per run, so it would - make the signed SBOM non-deterministic, and it leaks runner filesystem - layout into a published artifact. The URL is reduced to the wheel basename; - the SHA-256 alongside it is kept. -2. The inventory has no ``metadata.component``, so nothing in the document - says which artifact it describes. The wheel is promoted to that slot with - its digest, which is what ``verify`` later binds against. +runtime closure, then inventories it by **reading ``.dist-info`` metadata**. + +That last part is a security property, not a style choice. ``cyclonedx-py +environment`` inventories by *launching* the target interpreter, and starting a +Python process runs ``site`` processing — which executes any ``.pth`` file +beginning with ``import``. Those files come from the wheel's runtime dependency +closure, resolved unpinned from the index, so the previous implementation ran +third-party code inside the job that seals the release, before the handoff +digests were computed. Parsing metadata files cannot execute anything. + +Installation is wheels-only for the same reason: an sdist would run its build +backend during resolution. ``verify`` re-derives the wheel digest and refuses any SBOM that describes different bytes, a different version, or an environment containing a dev-only @@ -45,6 +46,8 @@ import tempfile import tomllib import venv +from email.parser import BytesParser +from email.policy import default as email_policy from pathlib import Path from typing import Any @@ -104,16 +107,6 @@ def _component_names(document: dict[str, Any]) -> set[str]: } -def _normalise_external_references(document: dict[str, Any], wheel_name: str) -> None: - """Replace build-machine ``file://`` URLs with the wheel basename.""" - - for component in [*document.get("components", []), document.get("metadata", {})]: - for reference in component.get("externalReferences", []) or []: - url = str(reference.get("url", "")) - if url.startswith("file://"): - reference["url"] = wheel_name - - def _assert_runtime_only(document: dict[str, Any], *, sbom_path: Path) -> None: forbidden = dev_only_distributions(REPO_ROOT / "pyproject.toml") present = sorted(forbidden & _component_names(document)) @@ -124,54 +117,92 @@ def _assert_runtime_only(document: dict[str, Any], *, sbom_path: Path) -> None: ) -def _promote_subject_component( - document: dict[str, Any], - *, - wheel_name: str, - wheel_version: str, - wheel_sha256: str, - wheel_filename: str, -) -> None: - """Move CycloneDX's own component for the wheel into ``metadata.component``. - - Inventing a fresh ``bom-ref`` here would leave the document describing the - subject twice: once as the invented metadata component and once as the - installed package in ``components``, with the dependency graph still keyed - by the original ref — so the declared subject would have no dependency node - at all. Reusing the emitted component keeps the graph intact and leaves - exactly one node for the subject. +def _site_packages(env_dir: Path) -> Path: + candidates = sorted(env_dir.glob("lib/python*/site-packages")) + [env_dir / "Lib/site-packages"] + for candidate in candidates: + if candidate.is_dir(): + return candidate + raise ConfigError(f"No site-packages directory under {env_dir}") + + +def _read_metadata(dist_info: Path) -> dict[str, Any]: + """Parse one ``.dist-info/METADATA`` without importing anything from it.""" + + path = dist_info / "METADATA" + if not path.is_file(): + raise ConfigError(f"Installed distribution has no METADATA: {dist_info}") + message = BytesParser(policy=email_policy).parsebytes(path.read_bytes()) + name = str(message.get("Name", "")).strip() + version = str(message.get("Version", "")).strip() + if not name or not version: + raise ConfigError(f"Installed distribution has no Name/Version: {dist_info}") + licenses = [ + str(value).strip() + for value in message.get_all("License-Expression", []) + or message.get_all("License", []) + or [] + if str(value).strip() + ] + requires = [ + str(value).strip() for value in message.get_all("Requires-Dist", []) or [] if str(value) + ] + return {"name": name, "version": version, "licenses": licenses, "requires": requires} + + +def _component(entry: dict[str, Any]) -> dict[str, Any]: + canonical = canonicalize_name(entry["name"]) + component: dict[str, Any] = { + "type": "library", + "bom-ref": f"{canonical}=={entry['version']}", + "name": entry["name"], + "version": entry["version"], + "purl": f"pkg:pypi/{canonical}@{entry['version']}", + } + if entry["licenses"]: + component["licenses"] = [{"license": {"name": value}} for value in entry["licenses"]] + return component + + +def inventory_environment(env_dir: Path) -> list[dict[str, Any]]: + """Inventory installed distributions by reading ``.dist-info`` directories. + + Deliberately does *not* launch the target interpreter. `cyclonedx-py + environment` does, and starting a Python process runs `site` processing — + which executes any ``.pth`` file beginning with ``import``. Those files come + from the wheel's runtime dependency closure, resolved from the index, so + launching that interpreter would run third-party code inside the job that + seals the release, before the handoff digests are computed. Reading + metadata files cannot execute anything. """ - components = document.get("components", []) - subject = next( - ( - component - for component in components - if canonicalize_name(str(component.get("name", ""))) == wheel_name - and str(component.get("version", "")) == wheel_version - ), - None, - ) - if subject is None: - raise ConfigError( - f"SBOM does not inventory {wheel_name} {wheel_version}; the isolated " - "environment did not contain the wheel under test." - ) + site_packages = _site_packages(env_dir) + entries = [_read_metadata(path) for path in sorted(site_packages.glob("*.dist-info"))] + if not entries: + raise ConfigError(f"No installed distributions found under {site_packages}") + return entries - components.remove(subject) - hashes = [entry for entry in subject.get("hashes", []) or [] if isinstance(entry, dict)] - if not any( - str(entry.get("alg", "")).upper() == "SHA-256" - and str(entry.get("content", "")) == wheel_sha256 - for entry in hashes - ): - hashes.append({"alg": "SHA-256", "content": wheel_sha256}) - subject["hashes"] = hashes - properties = [entry for entry in subject.get("properties", []) or [] if isinstance(entry, dict)] - properties.append({"name": WHEEL_FILENAME_PROPERTY, "value": wheel_filename}) - subject["properties"] = properties - document.setdefault("metadata", {})["component"] = subject +def _dependency_graph(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Edges between installed distributions, from ``Requires-Dist`` names. + + Environment markers and extras are not evaluated: the *component set* is + exact because it is what was installed, while the edge set is a documented + over-approximation of the closure. + """ + + by_canonical = {canonicalize_name(entry["name"]): entry for entry in entries} + graph = [] + for entry in entries: + canonical = canonicalize_name(entry["name"]) + depends = sorted( + { + f"{name}=={by_canonical[name]['version']}" + for name in (_requirement_name(item) for item in entry["requires"]) + if name in by_canonical and name != canonical + } + ) + graph.append({"ref": f"{canonical}=={entry['version']}", "dependsOn": depends}) + return graph def build_release_sbom(*, wheel_path: Path, output_path: Path) -> dict[str, Any]: @@ -201,6 +232,11 @@ def build_release_sbom(*, wheel_path: Path, output_path: Path) -> dict[str, Any] "install", "--quiet", "--no-input", + # Wheels only: an sdist would execute its build backend during + # resolution, which is exactly the code execution this module + # is structured to avoid. + "--only-binary", + ":all:", str(wheel_path), ], check=False, @@ -211,37 +247,37 @@ def build_release_sbom(*, wheel_path: Path, output_path: Path) -> dict[str, Any] raise ConfigError( f"Unable to install {wheel_path} into an isolated environment: {install.stderr}" ) + entries = inventory_environment(env_dir) - raw_path = Path(workdir) / "raw-sbom.json" - generate = subprocess.run( - [ - sys.executable, - "-m", - "cyclonedx_py", - "environment", - "--output-reproducible", - "--of", - "JSON", - "-o", - str(raw_path), - str(env_python), - ], - check=False, - capture_output=True, - text=True, - ) - if generate.returncode != 0: - raise ConfigError(f"cyclonedx-py failed for {wheel_path}: {generate.stderr}") - document = json.loads(raw_path.read_text(encoding="utf-8")) - - _normalise_external_references(document, wheel_path.name) - _promote_subject_component( - document, - wheel_name=wheel_name, - wheel_version=wheel_version, - wheel_sha256=wheel_sha256, - wheel_filename=wheel_path.name, + subject = next( + ( + entry + for entry in entries + if canonicalize_name(entry["name"]) == wheel_name and entry["version"] == wheel_version + ), + None, ) + if subject is None: + raise ConfigError( + f"SBOM does not inventory {wheel_name} {wheel_version}; the isolated " + "environment did not contain the wheel under test." + ) + + subject_component = _component(subject) + subject_component["hashes"] = [{"alg": "SHA-256", "content": wheel_sha256}] + subject_component["properties"] = [{"name": WHEEL_FILENAME_PROPERTY, "value": wheel_path.name}] + + document = { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": {"component": subject_component}, + # The subject appears once, as the metadata component; listing it again + # here would leave consumers unable to tell which node the document is + # about. + "components": [_component(entry) for entry in entries if entry["name"] != subject["name"]], + "dependencies": _dependency_graph(entries), + } _assert_runtime_only(document, sbom_path=output_path) output_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/verify_qualification_binding.py b/scripts/verify_qualification_binding.py index 00955138..0f52efe4 100644 --- a/scripts/verify_qualification_binding.py +++ b/scripts/verify_qualification_binding.py @@ -109,8 +109,54 @@ def verify_qualification_binding( summary.get("receipt_count") == REQUIRED_CASE_COUNT, "summary receipt_count is not 100", ) - _require(errors, summary.get("unsafe_auto_pass_count") == 0, "artifact reports an unsafe pass") - _require(errors, summary.get("runtime_failure_count") == 0, "artifact reports runtime failures") + + # Derived from the cases, not read from the summary. The summary is a + # claim the artifact makes about itself; an attacker who can produce a + # validly signed artifact can also write `unsafe_auto_pass_count: 0` above + # a hundred cases that say otherwise. + derived_unsafe = sum( + 1 + for case in cases + if isinstance(case, dict) + and case.get("expected_decision") != "passed" + and case.get("actual_decision") == "passed" + ) + derived_runtime_failures = sum( + 1 for case in cases if isinstance(case, dict) and case.get("runtime_failure") + ) + receipts = [ + str(case.get("receipt_sha256", "")) + for case in cases + if isinstance(case, dict) and case.get("receipt_sha256") + ] + + _require(errors, derived_unsafe == 0, "cases contain an unsafe auto-pass") + _require(errors, derived_runtime_failures == 0, "cases contain a runtime failure") + _require( + errors, + len(receipts) == REQUIRED_CASE_COUNT, + f"cases carry {len(receipts)} receipts, not {REQUIRED_CASE_COUNT}", + ) + _require( + errors, + all(SHA256_PATTERN.fullmatch(digest) for digest in receipts), + "a case receipt digest is malformed", + ) + _require(errors, len(set(receipts)) == len(receipts), "case receipt digests are not unique") + + # The summary must agree with what the cases actually say; a disagreement + # means the artifact is internally inconsistent regardless of which side is + # right. + _require( + errors, + summary.get("unsafe_auto_pass_count") == derived_unsafe, + "summary unsafe_auto_pass_count disagrees with the cases", + ) + _require( + errors, + summary.get("runtime_failure_count") == derived_runtime_failures, + "summary runtime_failure_count disagrees with the cases", + ) # The binding. Everything above is a claim; this is what ties the claim to # the bytes that will reach the index. diff --git a/tests/test_release_pipeline.py b/tests/test_release_pipeline.py index 692bad33..9432b7a2 100644 --- a/tests/test_release_pipeline.py +++ b/tests/test_release_pipeline.py @@ -1056,8 +1056,11 @@ def test_the_sealer_installs_only_a_hash_locked_toolchain() -> None: lockfile = (REPO_ROOT / "constraints/release-seal.txt").read_text(encoding="utf-8") - for pinned in ("build==", "hatchling==", "sigstore==", "cyclonedx-bom=="): + for pinned in ("build==", "hatchling==", "sigstore=="): assert pinned in lockfile + # CycloneDX left the closure when SBOM generation stopped launching the + # target interpreter; the sealer's trusted surface shrank with it. + assert "cyclonedx" not in lockfile assert lockfile.count("--hash=sha256:") > 20 assert ">=" not in lockfile.replace("# ", "") @@ -1106,7 +1109,16 @@ def _artifact(**overrides: Any) -> Path: "static_only": True, "runtime_behavior_proven": False, "failures": [], - "cases": [{"id": f"c{i}"} for i in range(100)], + "cases": [ + { + "id": f"c{i}", + "expected_decision": "blocked", + "actual_decision": "blocked", + "receipt_sha256": f"{i:064x}", + "runtime_failure": False, + } + for i in range(100) + ], "summary": { "total_cases": 100, "receipt_count": 100, @@ -1133,7 +1145,7 @@ def _artifact(**overrides: Any) -> Path: for overrides, expected in [ ({"qualification_tier": "test"}, "tier is not beta"), ({"production_qualified": False}, "not production_qualified"), - ({"cases": [{"id": "c0"}]}, "cases, not 100"), + ({"cases": [{"id": "c0", "receipt_sha256": "0" * 64}]}, "cases, not 100"), ({"runtime_behavior_proven": True}, "runtime behaviour"), ({"failures": ["x"]}, "reports failures"), ]: @@ -1202,3 +1214,193 @@ def test_deployment_prerequisites_are_documented_with_their_residuals() -> None: assert prerequisite in runbook # The honest limit about the workflow being candidate-controlled. assert "the workflow at that tag" in runbook + + +# -------------------------------------------------------------------------- +# Review round 4 — code execution inside the sealer, and the draft/latest bug +# -------------------------------------------------------------------------- + + +def test_the_sbom_inventory_never_executes_environment_code(tmp_path: Path) -> None: + """Reproduction of the finding: `cyclonedx-py environment` inventories by + *launching* the target interpreter, and interpreter startup runs `site` + processing, which executes any `.pth` file beginning with `import`. Those + files come from the wheel's runtime closure, resolved unpinned from the + index — so the previous implementation ran third-party code inside the job + that seals the release, before the handoff digests were computed. + """ + + import sys + import venv as venv_module + + from scripts.release_sbom import inventory_environment + + env_dir = tmp_path / "runtime" + venv_module.EnvBuilder(with_pip=False, symlinks=sys.platform != "win32").create(env_dir) + site_packages = next(env_dir.glob("lib/python*/site-packages"), None) or ( + env_dir / "Lib/site-packages" + ) + site_packages.mkdir(parents=True, exist_ok=True) + + # A minimal installed distribution, plus a .pth that would run on startup. + dist_info = site_packages / "victim-1.0.dist-info" + dist_info.mkdir() + (dist_info / "METADATA").write_text( + "Metadata-Version: 2.1\nName: victim\nVersion: 1.0\n", encoding="utf-8" + ) + marker = tmp_path / "executed" + (site_packages / "zz_evil.pth").write_text( + f"import pathlib; pathlib.Path({str(marker)!r}).write_text('x')\n", encoding="utf-8" + ) + + entries = inventory_environment(env_dir) + + assert [entry["name"] for entry in entries] == ["victim"] + assert not marker.exists(), "inventorying executed a .pth from the target environment" + + +def test_the_sbom_generator_needs_no_third_party_tooling() -> None: + """Removing the launch also removed CycloneDX from the sealer's closure.""" + + seal = (REPO_ROOT / "constraints/release-seal.txt").read_text(encoding="utf-8") + source = (REPO_ROOT / "scripts/release_sbom.py").read_text(encoding="utf-8") + + assert "cyclonedx" not in seal + assert "cyclonedx_py" not in source + # Wheels only: an sdist would run its build backend during resolution. + assert "--only-binary" in source + + +def test_the_wheel_is_built_with_the_locked_backend() -> None: + """`python -m build` defaults to creating a fresh isolated environment and + re-resolving the pyproject build requirements, which puts the backend's own + transitive dependencies outside the hash-locked closure.""" + + artifact = _load_workflow("release-verify.yml")["jobs"]["artifact"] + build_step = artifact["steps"][_step_index(artifact, "python -m build --wheel")]["run"] + + assert "--no-isolation" in build_step + + +def test_a_draft_is_never_created_as_latest() -> None: + """GitHub rejects `draft: true` together with `make_latest: true`, so + requesting it at create time fails every first stable release during + staging, before PyPI is touched.""" + + release = _load_workflow("release.yml") + stage = _job_commands(release["jobs"]["stage"]) + finalize = _job_commands(release["jobs"]["finalize"]) + + # Creation never requests latest; a stable tag explicitly opts out. + assert "--latest=false" in stage + assert '"${create_maturity}"' in stage + assert 'create_maturity="--latest"' not in stage + # Maturity is applied only when the draft is lifted. + assert 'maturity="--latest"' in finalize + assert "--draft=false" in finalize + + +def test_the_sealer_seals_the_bytes_the_policy_gate_accepted() -> None: + """The gate and the sealer download from the same mutable URLs at different + times; without this the gate proves a policy about one artifact while the + sealer seals another.""" + + workflow = _load_workflow("release-verify.yml") + gate_outputs = workflow["jobs"]["tests"]["outputs"] + sealer = _job_commands(workflow["jobs"]["artifact"]) + + assert "qualified_wheel_sha256" in gate_outputs + assert "qualification_sha256" in gate_outputs + assert "GATE_WHEEL_SHA256" in sealer + assert "GATE_QUALIFICATION_SHA256" in sealer + assert sealer.count("sha256sum --check --strict") >= 2 + + +def test_qualification_counts_are_derived_from_cases_not_the_summary(tmp_path: Path) -> None: + """The summary is a claim the artifact makes about itself. An attacker able + to produce a validly signed artifact can also write `unsafe_auto_pass_count: + 0` above a hundred cases that say otherwise.""" + + from scripts.verify_qualification_binding import verify_qualification_binding + + wheel = _write_wheel(tmp_path / WHEEL_FILENAME) + cases = [ + { + "id": f"c{index}", + "expected_decision": "blocked", + "actual_decision": "blocked", + "receipt_sha256": f"{index:064x}", + "runtime_failure": False, + } + for index in range(100) + ] + payload = { + "qualification_tier": "beta", + "qualified": True, + "production_qualified": True, + "static_only": True, + "runtime_behavior_proven": False, + "failures": [], + "cases": cases, + "summary": { + "total_cases": 100, + "receipt_count": 100, + "unsafe_auto_pass_count": 0, + "runtime_failure_count": 0, + }, + "inputs": { + "wheel_name": "agents-shipgate", + "wheel_version": "9.9.9", + "engine_version": "9.9.9", + "wheel_sha256": _digest(wheel), + }, + } + path = tmp_path / "qualification.json" + path.write_text(json.dumps(payload), encoding="utf-8") + assert verify_qualification_binding(qualification_path=path, wheel_path=wheel, tag="v9.9.9") + + # A case that auto-passed something it should not have, with the summary + # still claiming zero. + payload["cases"][0] = { + "id": "c0", + "expected_decision": "blocked", + "actual_decision": "passed", + "receipt_sha256": f"{0:064x}", + "runtime_failure": False, + } + path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(ReleaseError, match="cases contain an unsafe auto-pass"): + verify_qualification_binding(qualification_path=path, wheel_path=wheel, tag="v9.9.9") + + # Duplicated receipts are not 100 distinct receipts. + payload["cases"][0] = dict(cases[0]) + payload["cases"][1] = dict(cases[0], id="c1") + path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(ReleaseError, match="receipt digests are not unique"): + verify_qualification_binding(qualification_path=path, wheel_path=wheel, tag="v9.9.9") + + +def test_the_publisher_lockfile_is_constrained_by_a_workflow_allowlist() -> None: + """`--require-hashes` constrains integrity, not choice: the lockfile comes + from the candidate commit, so nothing in it stops a candidate adding a + package to a job that can mint a PyPI token.""" + + publish = _load_workflow("release.yml")["jobs"]["publish"] + install = publish["steps"][_step_index(publish, "--require-hashes")]["run"] + + assert "allowed=" in install + assert "not in this workflow's allowlist" in install + # Every distribution the committed lockfile actually needs must be listed, + # or the release fails on a legitimate lockfile. + import re as _re + + locked = { + name.lower().replace("_", "-").replace(".", "-") + for name in _re.findall( + r"^([A-Za-z0-9][A-Za-z0-9._-]*)==", + (REPO_ROOT / "constraints/release-publish.txt").read_text(encoding="utf-8"), + _re.MULTILINE, + ) + } + allowed = set(_re.findall(r"[a-z0-9][a-z0-9-]*", install.split("grep -oE")[0])) + assert locked <= allowed, f"not allowlisted: {sorted(locked - allowed)}" From 605bc8ada83cb87dd5ccd34c6ffb546655cd3ce5 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Mon, 10 Aug 2026 14:01:00 -0700 Subject: [PATCH 8/8] fix(release): apply the install allowlist to every publication-side job Self-review after round 4. The lockfile allowlist added in d3693977 guarded only the OIDC job, but `stage` and `finalize` install the same candidate-controlled lockfile and both can rewrite a public release. Guarding one of the three was an inconsistency that read as intent. The fetch, allowlist check and hash-verified install move into a composite action at .github/actions/install-release-toolchain, used by all three jobs, so the allowlist is single-sourced and they cannot drift apart. `finalize` keeps a separate step for its standard-library scripts, which is a different concern from installing a toolchain. The contract test now asserts all three jobs use the shared action and that none installs the lockfile directly, rather than checking one job's text. Co-Authored-By: Claude Opus 5 --- .../install-release-toolchain/action.yml | 60 ++++++++++++++ .github/workflows/release.yml | 83 ++++++------------- tests/test_release_pipeline.py | 42 +++++++--- 3 files changed, 115 insertions(+), 70 deletions(-) create mode 100644 .github/actions/install-release-toolchain/action.yml diff --git a/.github/actions/install-release-toolchain/action.yml b/.github/actions/install-release-toolchain/action.yml new file mode 100644 index 00000000..a40281b1 --- /dev/null +++ b/.github/actions/install-release-toolchain/action.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40dc9c77..9ec93c1a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,10 +60,12 @@ jobs: - name: Install the hash-locked verification toolchain # Needed to verify signature bundles on an already-published release. - # Hash-locked and project-free, like the other publication-side jobs. - run: | - python -m pip install --require-hashes \ - --requirement constraints/release-publish.txt + # Same constrained install as the other publication-side jobs: this job + # can rewrite a public release, so what it installs is constrained by + # choice as well as by digest. + uses: ./.github/actions/install-release-toolchain + with: + lockfile-url: https://raw.githubusercontent.com/${{ github.repository }}/${{ needs.verify.outputs.source_sha }}/constraints/release-publish.txt - name: Confirm the tag still points at the verified commit # Guards a tag moved (or deleted and recreated) after verification. @@ -266,53 +268,15 @@ jobs: python-version: "3.12" - name: Install the hash-locked publication toolchain - env: - LOCKFILE_URL: https://raw.githubusercontent.com/${{ github.repository }}/${{ needs.verify.outputs.source_sha }}/constraints/release-publish.txt - run: | - set -euo pipefail - # No candidate *code* runs in this job. An earlier revision fetched - # `release_publication.py` here and ran it before `uv publish`; - # "standard library only" is not a trust boundary, because the file - # still came from the candidate tree and could have rewritten the - # wheel or requested an OIDC token directly. The index query below - # therefore uses curl and jq, which the runner provides. - curl --fail --location --proto '=https' --proto-redir '=https' \ - --output release-publish.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. - # - # The allowlist below is the constraint on choice. It lives in this - # workflow rather than in the lockfile, so widening the set means - # editing `.github/**` — a small, reviewed diff — instead of adding a - # line to a 400-line generated file where it would pass unnoticed. - # This raises the review floor; it is not an independent trust root, - # because this workflow 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-publish.txt \ - | sed 's/==$//' | tr 'A-Z_.' 'a-z--' | sort -u > requested.txt - while read -r name; do - case " ${allowed} " in - *" ${name} "*) ;; - *) - echo "::error::Publication lockfile requests ${name}, which is not in this workflow's allowlist." - exit 1 - ;; - esac - done < requested.txt - echo "OK: every locked distribution is on the workflow allowlist." - - python -m pip install --require-hashes --requirement release-publish.txt + # No candidate *code* runs in this job. An earlier revision fetched + # `release_publication.py` here and ran it before `uv publish`; + # "standard library only" is not a trust boundary, because the file + # still came from the candidate tree and could have rewritten the + # wheel or requested an OIDC token directly. The index query below + # therefore uses curl and jq, which the runner provides. + uses: ./.github/actions/install-release-toolchain + with: + lockfile-url: https://raw.githubusercontent.com/${{ github.repository }}/${{ needs.verify.outputs.source_sha }}/constraints/release-publish.txt - name: Confirm the tag still points at the verified commit env: @@ -446,22 +410,23 @@ jobs: python-version: "3.12" - name: Install the hash-locked verification toolchain + uses: ./.github/actions/install-release-toolchain + with: + lockfile-url: https://raw.githubusercontent.com/${{ github.repository }}/${{ needs.verify.outputs.source_sha }}/constraints/release-publish.txt + + - name: Fetch the standard-library verification scripts + # Fetched by immutable SHA rather than checked out: this job mutates a + # public release, so it runs no project code. These two modules import + # nothing beyond the standard library. env: RAW_BASE: https://raw.githubusercontent.com/${{ github.repository }}/${{ needs.verify.outputs.source_sha }} run: | set -euo pipefail - # Same fetch-by-immutable-SHA approach as the publish job: this job - # mutates a public release, so it runs no project code either. mkdir -p tools - for path in \ - constraints/release-publish.txt \ - scripts/release_publication.py \ - scripts/_release_support.py - do + for path in scripts/release_publication.py scripts/_release_support.py; do curl --fail --location --proto '=https' --proto-redir '=https' \ --output "tools/$(basename "${path}")" "${RAW_BASE}/${path}" done - python -m pip install --require-hashes --requirement tools/release-publish.txt - name: Confirm the tag still points at the verified commit # Re-peeled before any mutation here. PyPI has the bytes for source A diff --git a/tests/test_release_pipeline.py b/tests/test_release_pipeline.py index 9432b7a2..8b1642f2 100644 --- a/tests/test_release_pipeline.py +++ b/tests/test_release_pipeline.py @@ -407,8 +407,11 @@ def test_the_token_bearing_job_installs_no_project_code() -> None: assert "pip install -e" not in commands assert '".[dev]"' not in commands - # Installs only the hash-locked closure, and verifies hashes on install. - assert "--require-hashes" in commands + # Installs only via the shared constrained action, which hash-verifies and + # allowlists what it installs. + assert any( + "install-release-toolchain" in str(step.get("uses", "")) for step in publish["steps"] + ) assert "constraints/release-publish.txt" in json.dumps(publish) # No checkout of the repository at all. assert not any("actions/checkout" in str(step.get("uses", "")) for step in publish["steps"]) @@ -1038,7 +1041,9 @@ def test_finalisation_runs_no_project_code_either() -> None: assert not any("actions/checkout" in str(step.get("uses", "")) for step in finalize["steps"]) assert "pip install -e" not in commands - assert "--require-hashes" in commands + assert any( + "install-release-toolchain" in str(step.get("uses", "")) for step in finalize["steps"] + ) # Uses the stdlib-only scripts fetched by immutable SHA. assert "tools/release_publication.py" in commands @@ -1380,20 +1385,35 @@ def test_qualification_counts_are_derived_from_cases_not_the_summary(tmp_path: P verify_qualification_binding(qualification_path=path, wheel_path=wheel, tag="v9.9.9") -def test_the_publisher_lockfile_is_constrained_by_a_workflow_allowlist() -> None: +def test_every_publication_side_job_constrains_what_it_installs() -> None: """`--require-hashes` constrains integrity, not choice: the lockfile comes from the candidate commit, so nothing in it stops a candidate adding a - package to a job that can mint a PyPI token.""" + package to a job that can mint a PyPI token or rewrite a public release. - publish = _load_workflow("release.yml")["jobs"]["publish"] - install = publish["steps"][_step_index(publish, "--require-hashes")]["run"] + The allowlist lives in one composite action so the three jobs cannot drift + apart — an earlier revision guarded only the OIDC job. + """ - assert "allowed=" in install - assert "not in this workflow's allowlist" in install - # Every distribution the committed lockfile actually needs must be listed, - # or the release fails on a legitimate lockfile. import re as _re + release = _load_workflow("release.yml") + action = yaml.safe_load( + (REPO_ROOT / ".github/actions/install-release-toolchain/action.yml").read_text("utf-8") + ) + install = "\n".join(step["run"] for step in action["runs"]["steps"] if "run" in step) + + for job_name in ("stage", "publish", "finalize"): + job = release["jobs"][job_name] + uses = [str(step.get("uses", "")) for step in job["steps"]] + assert any("install-release-toolchain" in item for item in uses), job_name + # No job installs the lockfile directly, bypassing the allowlist. + assert "pip install --require-hashes" not in _job_commands(job), job_name + + assert "not on the allowlist" in install + assert "--require-hashes" in install + + # Every distribution the committed lockfile actually needs must be listed, + # or the release fails on a legitimate lockfile. locked = { name.lower().replace("_", "-").replace(".", "-") for name in _re.findall(