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/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-rehearsal.yml b/.github/workflows/release-rehearsal.yml new file mode 100644 index 00000000..591cbfdd --- /dev/null +++ b/.github/workflows/release-rehearsal.yml @@ -0,0 +1,54 @@ +name: Release Rehearsal + +# A release candidate used to be first-run at the same moment publication +# became possible: the only way to exercise release.yml was to push a `v*` tag, +# which is also the only thing that can publish. Steps added after v0.15.0 had +# therefore never executed when they were relied upon to gate a release. +# +# This workflow runs the identical verification path — same build, same +# qualification validation, same tests, same audit, same SBOM, same +# content-addressed handoff — by calling the same reusable workflow release.yml +# calls. What it does not contain is a publication job. +# +# Three independent things prevent this from publishing, so a misconfigured +# step cannot become a release: +# +# 1. There is no publication job in this file to instantiate. +# 2. `permissions: contents: read` at workflow level. A job here cannot be +# granted write, so `gh release create` and tag creation fail. +# 3. No `id-token: write` anywhere, so PyPI Trusted Publishing cannot mint a +# token. There is no `environment: pypi` either. +# +# See docs/release-runbook.md: a rehearsal on the candidate commit is a +# prerequisite for pushing a release tag. + +on: + workflow_dispatch: + inputs: + release_tag: + description: >- + Tag to rehearse against. Leave empty to derive v, + which is what the real tag will be. + required: false + type: string + default: "" + +permissions: + contents: read + +jobs: + rehearse: + name: Rehearse candidate + permissions: + contents: read + uses: ./.github/workflows/release-verify.yml + with: + # `github.sha` — the immutable commit the dispatch resolved to — never a + # branch name or a free-form ref input. The verification workflow checks + # out independently in its `tests` and `artifact` jobs, so a mutable ref + # could otherwise be tested as commit A and sealed as commit B, while + # `stage` later located the run by its original `head_sha`. Choose which + # commit to rehearse with the workflow_dispatch ref selector. + ref: ${{ github.sha }} + release_tag: ${{ inputs.release_tag }} + mode: rehearsal diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml new file mode 100644 index 00000000..ba652dfd --- /dev/null +++ b/.github/workflows/release-verify.yml @@ -0,0 +1,578 @@ +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.artifact.outputs.version }} + release_tag: + description: Tag the verified candidate is bound to. + 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.artifact.outputs.wheel_filename }} + wheel_sha256: + description: SHA-256 of the wheel that verification approved. + 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.artifact.outputs.manifest_sha256 }} + artifact_name: + description: Name of the uploaded candidate bundle. + value: ${{ jobs.artifact.outputs.artifact_name }} + +permissions: + contents: read + +jobs: + 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 + 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 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + 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: + 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 . + + - 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 + # 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}" + { + 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 + # 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): + # 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: + 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 }} + 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 + # 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." + 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}" + echo "release_tag=${tag}" + echo "source_sha=${source_sha}" + } >> "${GITHUB_OUTPUT}" + echo "OK: ${tag} matches pyproject.toml (${version}) at ${source_sha}." + + - 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 }} + run: | + missing=0 + for name in \ + QUALIFIED_WHEEL_URL \ + QUALIFIED_WHEEL_FILENAME \ + QUALIFICATION_JSON_URL \ + QUALIFICATION_BUNDLE_URL + 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 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 + # 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 + # `--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 + 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: 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 }} + QUALIFICATION_OIDC_ISSUER: ${{ steps.trust_roots.outputs.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: 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_qualification_binding.py \ + --qualification qualified-dist/safety-qualification.json \ + --wheel "${QUALIFIED_WHEEL}" \ + --tag "${RELEASE_TAG}" + + - 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. + env: + SOURCE_SHA: ${{ steps.candidate.outputs.source_sha }} + run: | + python scripts/verify_wheel_provenance.py \ + --built source-build/*.whl \ + --qualified "${QUALIFIED_WHEEL}" \ + --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: 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 }} + 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 \ + 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 "${SOURCE_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..9ec93c1a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,171 +5,548 @@ on: tags: - "v*" -permissions: - contents: write - id-token: write +# 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 +# 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: + # 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 + + stage: + name: Stage release + needs: verify runs-on: ubuntu-latest timeout-minutes: 15 - environment: pypi - + permissions: + contents: write + actions: read + outputs: + should_publish: ${{ steps.index.outputs.should_publish }} + index_state: ${{ steps.index.outputs.state }} + release_state: ${{ steps.release.outputs.release_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" + + - name: Install the hash-locked verification toolchain + # Needed to verify signature bundles on an already-published release. + # 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: 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. + - 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: | - 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." + 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: ${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 + 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: | + 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 - 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." + 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 + 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. 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: + WHEEL_FILENAME: ${{ needs.verify.outputs.wheel_filename }} + run: | + python scripts/release_sbom.py verify \ + --wheel "dist/${WHEEL_FILENAME}" \ + --sbom dist/agents-shipgate-sbom.json + + - 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: | + python scripts/release_publication.py pypi-state \ + --wheel "dist/${WHEEL_FILENAME}" \ + --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 + # 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 + create_maturity="--latest=false" + else + create_maturity="--prerelease" + fi + + 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 \ + "${create_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 + + # 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 + # `--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 \ + --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, complete and signed; 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 + # 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" - cache: pip - - name: Install - run: | - python -m pip install -e ".[dev]" - python -m pip install "uv==0.11.7" + - name: Install the hash-locked publication toolchain + # 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: Download configured qualification inputs + - name: Confirm the tag still points at the verified commit 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 }} + RELEASE_TAG: ${{ needs.verify.outputs.release_tag }} + SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} run: | - if [ -e qualified-dist ] || [ -L qualified-dist ]; then - echo "::error::qualified-dist must not pre-exist in the release checkout." - exit 1 + 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 - 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 - 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 - 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. - 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 . - - - name: Generate SBOM - run: | - if [ -e dist ] || [ -L dist ]; then - echo "::error::dist must not pre-exist before qualified artifact promotion." + if [ "${peeled}" != "${SOURCE_SHA}" ]; then + echo "::error::Tag ${RELEASE_TAG} now points at ${peeled:-}, not the verified ${SOURCE_SHA}." exit 1 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: 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: | + 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/${QUALIFIED_WHEEL_FILENAME}" \ + "dist/${WHEEL_FILENAME}" \ dist/agents-shipgate-sbom.json - name: Publish to PyPI with Trusted Publishing - run: uv publish --trusted-publishing always "dist/${QUALIFIED_WHEEL_FILENAME}" + # 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 }} + 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 + # 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)" - - name: Create GitHub release + 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 + ;; + esac + echo "Index state: ${state}" + + case "${state}" in + published_identical) + echo "Index already holds these exact bytes; completing the interrupted transaction." + exit 0 + ;; + 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: + 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] + # 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 + # reach PyPI. + permissions: + contents: write + steps: + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + 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 + mkdir -p tools + 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 + + - 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: + 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 }} + run: | + 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 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 + 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 \ + --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 + env: + 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 + 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: 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 + maturity="--prerelease" + fi + gh release edit "${RELEASE_TAG}" --draft=false "${maturity}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b5b7270..1c015d58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,96 @@ ## 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, 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 + 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`. - **Insufficient-evidence remediation now stays framework-aware from the decision engine through every primary short-form surface.** Semantic `incomplete_surface` gaps for frameworks with explicit inventory support now 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/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/constraints/release-seal.in b/constraints/release-seal.in new file mode 100644 index 00000000..c658ebbd --- /dev/null +++ b/constraints/release-seal.in @@ -0,0 +1,3 @@ +build==1.5.0 +hatchling==1.31.0 +sigstore==4.5.0 diff --git a/constraints/release-seal.txt b/constraints/release-seal.txt new file mode 100644 index 00000000..2581e599 --- /dev/null +++ b/constraints/release-seal.txt @@ -0,0 +1,570 @@ +# Hash-locked toolchain for the sealing job. +# +# 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. +# +# 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. +# +# Three direct requirements, with their full transitive closure pinned and +# hashed, installed with `--require-hashes`: +# +# 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. +# +# 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: +# +# 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 +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 +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 +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 +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 + # 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 +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via + # build + # hatchling +pathspec==1.1.1 \ + --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \ + --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 + # via hatchling +platformdirs==4.11.2 \ + --hash=sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d \ + --hash=sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4 + # via sigstore +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via hatchling +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 +pyproject-hooks==1.2.0 \ + --hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \ + --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 + # via build +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-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 +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 + # pydantic + # pydantic-core + # pyopenssl + # sigstore-models + # typing-inspection +typing-inspection==0.4.3 \ + --hash=sha256:5f42b23858a91e0b4ef521f5418f03a0da3c9216fd2995ef5e73463100e676cd \ + --hash=sha256:c5f9ec1530b5c1e2c9bc34a84d9a3466ed1b2f3f2fa9f901368d9c5596210e4d + # via pydantic +urllib3==2.7.0 \ + --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ + --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 + # via + # id + # requests + # tuf 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..997aa697 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -18,39 +18,58 @@ 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`: +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`: -| 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` | | `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 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..6a4251fa --- /dev/null +++ b/docs/release-evidence-policy-decision.md @@ -0,0 +1,132 @@ +# Decision Brief: The Evidence Bar for Pre-1.0 Tags + +**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 +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 +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. + +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 + +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 + +No tag publishes until a separate corpus-delivery issue produces the +independently labelled, adjudicated, receipt-bound artifact. + +- **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 + 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:** 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 + (`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..f960dcb1 --- /dev/null +++ b/docs/release-runbook.md @@ -0,0 +1,332 @@ +# 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 five jobs with an explicit, content-addressed handoff. + +| Job | Permissions | What it does | +|---|---|---| +| `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, 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, +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**, +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, 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`. + +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 + +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, 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 +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. + +### 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. + +The drill runs only in rehearsal mode — a real release must not spend its +budget on drills. + +### Re-deriving the timeout + +Each verification job is bounded separately, from observed hosted-runner +timings rather than an estimate: + +| 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 | | + +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 +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 + +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 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 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. `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: + +```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. + +## 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 +evidence** lives in reviewed code, and only **where the evidence lives** is +mutable. + +### Trust roots — reviewed code + +`.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 | +|---|---| +| `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 | + +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..afd24c5d --- /dev/null +++ b/scripts/_release_support.py @@ -0,0 +1,110 @@ +"""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 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.""" + + 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/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 new file mode 100644 index 00000000..22750893 --- /dev/null +++ b/scripts/release_publication.py @@ -0,0 +1,409 @@ +#!/usr/bin/env python3 +"""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 +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 + +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 +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`` + 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`` + 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 "$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" + 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 json +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +if __package__: + from scripts._release_support import ( + SHA256_PATTERN, + ReleaseError, + inspect_wheel, + sha256_file, + ) +else: # ``python scripts/release_publication.py`` + from _release_support import ( + SHA256_PATTERN, + ReleaseError, + 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 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 ReleaseError(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 _assert_closed_world( + 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`` 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 | required_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." + ) + 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( + *, + manifest_path: Path, + 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. + + ``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 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 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", []): + filename = str(asset.get("filename", "")) + listed.add(filename) + path = base / filename + if not path.is_file(): + errors.append(f"missing asset {filename}") + continue + digest = sha256_file(path) + if digest != asset.get("sha256"): + errors.append( + f"{filename} digest {digest} does not match the verified {asset.get('sha256')}" + ) + if errors: + raise ReleaseError("Candidate handoff rejected: " + "; ".join(errors)) + + _assert_closed_world( + manifest_path, base, listed, allowed_extra or set(), required_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 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 + 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 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 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 — 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: + state = _classify_published( + files, wheel_filename=wheel_path.name, wheel_sha256=wheel_sha256 + ) + + if state == "published_divergent": + 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, + "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", + 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" + ), + ) + 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) + 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 = 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" + ) + elif args.command == "verify-manifest": + manifest = verify_manifest( + manifest_path=args.manifest, + 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" + ) + 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 (ReleaseError, 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..7e94af89 --- /dev/null +++ b/scripts/release_sbom.py @@ -0,0 +1,400 @@ +#!/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 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 +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 re +import subprocess +import sys +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 + +if __package__: + from scripts._release_support import ReleaseError as ConfigError + from scripts._release_support import canonicalize_name, inspect_wheel +else: # ``python scripts/release_sbom.py`` + 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" + + +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 = {_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", ""))) + for component in document.get("components", []) + if component.get("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 _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. + """ + + 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 + + +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]: + """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", + # 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, + capture_output=True, + text=True, + ) + if install.returncode != 0: + raise ConfigError( + f"Unable to install {wheel_path} into an isolated environment: {install.stderr}" + ) + entries = inventory_environment(env_dir) + + 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) + 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_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." + ) + 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/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..0f52efe4 --- /dev/null +++ b/scripts/verify_qualification_binding.py @@ -0,0 +1,218 @@ +#!/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", + ) + + # 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. + _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 new file mode 100644 index 00000000..b83d50c8 --- /dev/null +++ b/scripts/verify_wheel_provenance.py @@ -0,0 +1,264 @@ +#!/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 + +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"] + +# 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 _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. + """ + + 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: + 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(built_tags)} vs {sorted(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: + 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..8b1642f2 --- /dev/null +++ b/tests/test_release_pipeline.py @@ -0,0 +1,1426 @@ +"""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 hashlib +import json +import zipfile +from pathlib import Path +from typing import Any + +import pytest +import yaml + +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 + +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" + + +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 {}) + 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 + # 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}") + + +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, qualified = _wheel_pair(tmp_path) + + 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, qualified = _wheel_pair(tmp_path, {"agents_shipgate/_backdoor.py": "import os\n"}) + + 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(ReleaseError): + 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, qualified = _wheel_pair(tmp_path, {"agents_shipgate/__init__.py": "x = 666\n"}) + + with pytest.raises(ReleaseError, match="content differs"): + verify_wheel_provenance(built_path=built, qualified_path=qualified) + + +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" / 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" + assert differences == [] + + with pytest.raises(ReleaseError, 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 + + +@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(ReleaseError, 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"]["artifact"] + + 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"]["artifact"] + build_step = verify["steps"][_step_index(verify, "python -m build --wheel")] + 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"]["artifact"] + 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", + 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 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 / 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(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 / WHEEL_FILENAME) + sbom_path = tmp_path / "sbom.json" + sbom_path.write_text( + 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 / 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(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 / WHEEL_FILENAME) + sbom_path = tmp_path / "sbom.json" + sbom_path.write_text(json.dumps({"bomFormat": "CycloneDX", "components": []}), encoding="utf-8") + + with pytest.raises(ReleaseError, match="no metadata.component"): + verify_release_sbom(wheel_path=wheel, sbom_path=sbom_path) + + +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.""" + + 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_the_pipeline_separates_verification_staging_publication_and_finalisation() -> None: + release = _load_workflow("release.yml") + + assert list(release["jobs"]) == ["verify", "stage", "publish", "finalize"] + assert release["jobs"]["verify"]["uses"] == "./.github/workflows/release-verify.yml" + 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.""" + + release = _load_workflow("release.yml") + verify_workflow = _load_workflow("release-verify.yml") + + assert release["permissions"] == {} + assert release["jobs"]["verify"]["permissions"] == {"contents": "read"} + assert verify_workflow["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"} + 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 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"]) + + +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"] + + # 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_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.""" + + stage = _load_workflow("release.yml")["jobs"]["stage"] + commands = _job_commands(stage) + + 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, "verify-manifest") < _step_index(finalize, "--draft=false") + + +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 --trusted-publishing")] + + # 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 "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: + """`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.""" + + release = _load_workflow("release.yml") + + 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 + + +# -------------------------------------------------------------------------- +# #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": 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(ReleaseError, 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 / WHEEL_FILENAME) + manifest = _manifest(tmp_path, wheel) + verified_digest = _digest(manifest) + + _write_wheel(wheel, {"agents_shipgate/_swapped.py": "z = 3\n"}) + _manifest(tmp_path, wheel) + + with pytest.raises(ReleaseError, match="artifact handoff was modified"): + verify_manifest(manifest_path=manifest, expected_sha256=verified_digest) + + +@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: + resolved = [ + {**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"] == "absent" + assert result["should_publish"] is True + + +def test_the_exact_published_wheel_completes_an_interrupted_transaction( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + 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.""" + + 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 / WHEEL_FILENAME) + + def _explode(*_args: Any, **_kwargs: Any) -> list[dict[str, Any]]: + raise ReleaseError("Unable to query https://pypi.org/pypi: timed out") + + monkeypatch.setattr("scripts.release_publication._fetch_release_files", _explode) + + with pytest.raises(ReleaseError, 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"]["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 + # 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"]["tests"], "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.""" + + 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(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: + workflow = _load_workflow("release-verify.yml") + source = (WORKFLOWS / "release-verify.yml").read_text(encoding="utf-8") + + # 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 + + +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, and is required +# -------------------------------------------------------------------------- + + +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_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"]["artifact"] + 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"]["artifact"] + + 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" + ) + + +# -------------------------------------------------------------------------- +# 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"]["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 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 "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: + 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 + # `--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( + 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 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 + + +# -------------------------------------------------------------------------- +# 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=="): + 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("# ", "") + + +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}", + "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, + "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", "receipt_sha256": "0" * 64}]}, "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 + + +# -------------------------------------------------------------------------- +# 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_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 or rewrite a public release. + + The allowlist lives in one composite action so the three jobs cannot drift + apart — an earlier revision guarded only the OIDC job. + """ + + 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( + 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)}" diff --git a/tests/test_safety_qualification_release.py b/tests/test_safety_qualification_release.py index 77b48314..dd651ae1 100644 --- a/tests/test_safety_qualification_release.py +++ b/tests/test_safety_qualification_release.py @@ -300,31 +300,63 @@ 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") + + # 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 + + # 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", "stage"] + + # 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: