Repository navigation
ci(release): hash-lock the release toolchain, dry-run and verify reproducible builds (code-scanning #14/#15) - #170
Conversation
#14/#15) pypi.yml built and inspected the distribution PyPI receives with `pip install build twine check-wheel-contents` after an unpinned `pip install --upgrade pip` -- no version, no digest. It now installs .github/requirements/release.txt (pip-compile --generate-hashes, Python 3.12: 39 exact pins, 533 SHA-256 digests) with --require-hashes --only-binary :all:, and builds with --no-isolation so the locked setuptools is the backend instead of an unpinned download. Measured: the wheel is file-for-file identical to an isolated build. check_release_pins.py (stdlib only; Quality Contract on merge and head, and pytest via tests/test_release_pin_gate.py) rejects release-workflow installs that are neither hash-locked nor local --no-deps wheels, lock entries without an exact pin or well-formed digest, locks that miss a top-level requirement or were compiled for another Python than the workflow runs, a locked backend below [build-system] requires, unlisted PyPI-publishing workflows, the pip-tools/click --no-index header bug, and inline YAML values the parser would reject -- the last because this change's own first draft wrote `--only-binary :all:` inline, which is invalid YAML that zizmor 1.30.1 skips with exit 0. Mutation probe: 22/22 mutants killed. Dependabot now tracks the lock directory. No runtime code, anchor, reported number or manifest field changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017XxmGgszzaCSRtdXnEVoHB
The release.in comment claimed Dependabot derives its pip-compile options from the lock header. It infers --generate-hashes/--allow-unsafe from the lock's content and takes only the interpreter from the header (dependabot-core#14137, #7740). The --no-index header bug still matters for the maintainer-facing regeneration command, and the gate's python-version check is what catches a refresh resolved for the wrong interpreter. Comment-only change; no lock, workflow or code change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017XxmGgszzaCSRtdXnEVoHB
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…ducible pypi.yml ran only for a published release or by hand, so its toolchain, build and QA gate were first exercised on release day (both v0.5.0 release runs failed there, on the unregistered Trusted Publisher). The build job now also runs on every pull request and every push to main, as PyPA's publishing guide recommends; only the release-gated publish job can upload. Superseded PR dry-runs are cancelled, release runs never. Each run builds twice from the same commit with SOURCE_DATE_EPOCH set to the commit time and stops unless the wheels are byte-identical and the sdists content-identical (.github/scripts/compare_dists.py). Measured: without the variable neither artifact repeats; with it the wheel is byte-identical while the sdist still differs in gzip header, member mtimes and owner, because setuptools' sdist ignores SOURCE_DATE_EPOCH (pypa/setuptools#2133) -- exactly the fields the comparison disregards. Artifact SHA-256 go to the job summary; dry-run artifacts keep 7 days. check_release_pins.py now checks each release.in specifier against the locked version (name coverage accepted build<1.6 against a 1.6.1 pin), refuses markers, option lines and direct references in release.in, and recognises uv/poetry/flit/hatch/pdm publish commands. tests/test_pypi_workflow_contract.py pinned the old "Manual dry-run" wording; it now asserts the dry-run message plus three new contracts: dry-run triggers reach the build job only (no OIDC, no environment), release runs are never cancelled, and the reproducibility step runs before the QA gate and the upload. Verified locally: pytest 1292 passed, anchors 21/21, ruff, mypy, quickstart; actionlint 1.7.12 and zizmor 1.30.1 clean; the build job's run blocks, extracted from the YAML and executed in a fresh clone, pass on the pull_request and release paths and fail on a wrong tag; mutation probe over both scripts 38/38 killed, controls green. The published 0.5.0 files match their PyPI digests and a rebuild from tag v0.5.0 (every packaged file, all 103 sdist members); only the wheel's Generator line differs (setuptools 83.0.0 vs 84.0.0). No runtime code, anchor, reported number or manifest field changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017XxmGgszzaCSRtdXnEVoHB
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 429ecf86ba
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
No conflicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ds; clean rebuild Six Codex P2 findings on 429ecf8 (four reproduced by Equalita as silent passes): - check_release_pins.py:197 folded run blocks: workflows are decoded by a strict stdlib YAML subset reader (|, |-, >, >- with YAML folding; anchors, aliases, tags, multi-line plain/quoted scalars, duplicate keys and tabs are refused, and a refused workflow fails the gate). A shell line naming pip and install without a recognised invocation now fails too. - :336 local-wheel exception: literal dist/*.whl paths only (no expansion). - :373 lock markers: evaluated for the lock's Python (python_version only; anything else fails closed); an excluded entry is not a pin. - :680 publisher inventory: uses: values and shlex tokens, so `twine upload` and continued lines are found; an unreadable workflow cannot be ruled out. - compare_dists.py:73: sdist PAX records compared except time/owner keys. - pypi.yml:66: the second build runs in a fresh git worktree of HEAD. tests/test_release_gate_review_findings.py: 60 tests; against the 429ecf8 scripts and pypi.yml the finding tests fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No conflicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The whitespace-collapsed substring net already finds 'twine upload', so that case did not discriminate the token scan (DK: BLIND). Options between tool and sub-command and a quoted tool name reach only the token scan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"A construct the gate does not understand passes silently", aimed at the new code: installs and uploads whose command is decided at run time were still invisible. - pip whose sub-command is an expansion (`pip $CMD build`), and any command naming install through an expansion (`$PIP install build`, `python -m $MODULE install build`) fail closed; the pip/install prefilter is case-insensitive. - publisher inventory: a `uses` the scan cannot open (local action outside .github/workflows, another repository's workflow) and an upload/publish through an expansion (`$TOOL upload`) cannot be cleared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…7amo No conflicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Summary
The release job that builds the distribution PyPI receives installed its toolchain with
python -m pip install --upgrade pipandpip install build twine check-wheel-contents. Neither install had a version or a digest, so the tools that built and inspected the published artifact were whatever the index served at that minute. These are the twopypi.ymllines behind code-scanning alerts #14/#15 (OpenSSF Scorecard Pinned-Dependencies), which the 2026-09-22 handoff scheduled as a separate CI PR, not mixed into a numerics PR.This PR installs the toolchain from a hash lock and adds a fail-closed gate that keeps the release path in that state.
.github/requirements/release.in→release.txt. The lock is compiled withpip-compile --allow-unsafe --generate-hashes --strip-extrasunder Python 3.12, the release job's interpreter. It holds 39 exact pins and 533 SHA-256 digests.pypi.yml. The toolchain now installs with--require-hashes --only-binary :all: -r .github/requirements/release.txt:python -m build --no-isolation, so the locked setuptools is the build backend. An isolated build would download an unpinned setuptools at build time, and that was the last unpinned input to the artifact's build.dependabot.yml. A newpipentry for/.github/requirementshas the same weekly schedule and 7-day cooldown as the rest. Without it the lock would only ever get older..github/scripts/check_release_pins.py. Stdlib only. The Quality Contract runs it on both the merge plane and the head plane, andpytestruns it throughtests/test_release_pin_gate.py. It rejects each of the following:pip installthat is neither--require-hashes -r <lock>nor--no-depswith local*.whlpaths only. The check covers pip global options placed beforeinstall, and fails closed on spellings it cannot parse, such asuv pip install,sudo pip installand"$PY" -m pip install.==pin or without a well-formed digest, and nested-r/-c/-elines..in.python-version.[build-system] requires.--generate-hashesor carries--no-index. See the negative results below.RELEASE_WORKFLOWS.Round 2 (
429ecf8): the release path is exercised before release day and proven reproduciblebuildjob ofpypi.ymlnow also runs on every pull request and every push tomain, as PyPA's publishing guide recommends. Before this, the workflow ran only for a published release or by hand, so its toolchain, build and QA gate were first tested on release day. Bothreleaseruns for v0.5.0 failed that way, there on the Trusted Publisher, which was not yet registered.publishcan upload, and it still runs only for a published release behindPYPI_PUBLISH_ENABLED.id-tokenand noenvironment.SOURCE_DATE_EPOCHset to the commit time, and stops unless the wheels are byte-identical and the sdists content-identical (.github/scripts/compare_dists.py).SOURCE_DATE_EPOCH(pypa/setuptools#2133, open). The comparison disregards exactly those fields and nothing else.release.inspecifier must hold for the locked version. Coverage by name alone acceptedbuild<1.6against a lock pinning 1.6.1;release.inare refused;uv/poetry/flit/hatch/pdmpublish commands are recognised.tests/test_pypi_workflow_contract.pypinned the old "Manual dry-run" wording. It now asserts the new message plus three new contracts:docs/DEVELOPMENT_MIGRATION_0.6.0.dev0.mddescribes the release workflow and is updated to match.No runtime code, anchors, reported numbers or run-manifest fields change.
Scope
Verification
pytest -qpasses locally: 1292 passed at429ecf8, exit 0 (Python 3.11, onmain87e9539)tests/test_anchors.pyunchanged (21/21)MANIFEST_SCHEMA.json: n/a, not touched.github/workflows/: every action remains full-SHA pinned (check_workflow_hardening.pygreen), token permissions are unchanged, zizmor 1.30.1 reports no findings onpypi.ymlandquality-contract.yml, and actionlint 1.7.12 reports nothing on all 10 workflows.### Securityentry states only measured facts. No release, PyPI or DOI status claim is made.quality contractjob and its(head)job.pypi.ymlis not a required workflow, so its newpull_requesttrigger cannot strand a required check.check_agent_branch_triggers.pyis green.Evidence (local, round 1 on 2026-09-25, round 2 on 2026-09-26)
git archiveofbfbc211, in a fresh Python 3.12 venv.21caa3donly changes a comment inrelease.in:pip checkfinds no broken requirements;python -m build --no-isolationsucceeds;twine check --strictpasses for both the wheel and the sdist;check-wheel-contentsreports OK.--no-isolationwheel is file-for-file identical (diff -rof the unpacked wheels) to an isolated build. Both used setuptools 84.0.0. The only third-party build hooks in the environment belong to setuptools itself, pluscffi'scffi_moduleskeyword, which this project does not use.main's previouspypi.ymlrejects exactly lines 27 and 28 (the two flagged installs) and reports that no hash-locked install exists.tomllibis 3.11+. The pytest wrapper is skipped there with that reason.runblocks were extracted frompypi.ymlwith PyYAML and executed in a fresh clone. They pass on thepull_requestpath and on thereleasepath with a matching tag, and fail at the identity step on a wrong tag.f9f4a83) built the wheel894ca37a…a8be1afdon the runner, and my local rebuild of that commit, on a different machine with Python 3.12.3, produced the same bytes. Anyone can verify a published wheel from its tag and the lock.v0.5.0reproduces every packaged file of the wheel and all 103 sdist members. The only difference is the wheel'sGenerator: setuptools (83.0.0)line versus today's 84.0.0, plusRECORD, which hashes that line. That is the toolchain drift the lock now removes. The 0.5.0 files also carry PEP 740 provenance (PyPI Integrity API: HTTP 200).Negative results recorded
run: python -m pip install ... --only-binary :all: -r ...inline. Inside a plain YAML scalar,:is a mapping indicator, so the whole file stopped parsing (PyYAML:mapping values are not allowed here, line 31, column 71). zizmor 1.30.1 skipped the unparsable file with aWARNand still exited 0 with "No findings". A release-only workflow would therefore first have failed on release day. The command is now a block scalar, and the gate rejects that class of line in release workflows.--no-indexinto the lock header. The header is the documented regeneration command a maintainer copies, and re-running it would resolve against no index at all. The lock was recompiled withclick<8.3, and the pins and hashes were byte-identical.release.indocuments the caveat, and the gate rejects such a header.release.incomment and of this description said that Dependabot derives its pip-compile options from the header. According to dependabot-core #7740 and #14137, Dependabot infers--generate-hashes/--allow-unsafefrom the lock's content and takes only the interpreter from the header'swith Python X.Y. Commit21caa3dcorrects the comment. If a Dependabot refresh ever resolves for a different Python, the gate'spython-versioncheck turns that update red rather than letting wrongly resolved markers through. I have not tested this live.tests/test_pypi_workflow_contract.pyalready pins parts ofpypi.yml, including the literal "Manual dry-run verified". Round 1 happened not to touch that line. Round 2 did, and the full local suite caught it before the push. Before editing a workflow, searchtests/for its file name.Deliberately out of scope
ci.yml,ci-qutip.yml,ci-reusable-pilot.yml,ci-python-local.yml) still install-e .[dev,qutip]and their tools without hashes. They float on purpose so that upstream breakage surfaces early, and no artifact from them is published. Scorecard may keep flagging them. Locking them would need a per-Python dev lock for 3.10 to 3.14 and is a separate decision.self-repositoryfinding (low) in the untouchedci-reusable-pilot.yml. It is newer than the zizmor pinned in CI and is not part of this change.actions/upload-artifact/download-artifactv4 in the release path target Node 20. GitHub runs them on Node 24 and logs a deprecation warning. Bumping them needs verified SHA pins and belongs to the Dependabot action updates, not this change.Test plan
pytest -q(full suite): 1292 passed at429ecf8, exit 0pytest tests/test_anchors.py -v: 21/21ruff check src tests benchmarks: cleanmypy src/liouscope: no issues in 55 source filespython examples/quickstart.py: exit 0.github/scripts/check_{workflow_hardening,agent_branch_triggers,claim_safety,release_pins}.py: all exit 0.github/scripts/test_release_pins.py(23 tests),test_compare_dists.py(10 tests) andtest_agent_branch_triggers.py: OKrunblocks: thepull_requestandreleasepaths pass, a wrong tag fails429ecf8, including the newpypi.ymldry-run: 38 check runs, 34 succeeded, 4 skipped (3 template placeholders andpublish, which is correct for a PR), 0 failed. The dry-runbuildjob took 20 s.f9f4a83(runner, Python 3.12.14) has SHA-256894ca37a888c7b439c25d4874af6b7304b0df8727e92ebce2ed42bd2a8be1afd, and a local rebuild of the same commit on a different machine (Python 3.12.3) produces the identical hashQuality contract
Reproducibility note
No numerical result changes. Lock regeneration: from
.github/requirements/, with Python 3.12, pip-tools 7.6.1 and click<8.3, runpip-compile --allow-unsafe --generate-hashes --strip-extras release.in.References:
--generate-hasheschecks/raw/shell_download_validate.go(isUnpinnedPipInstall:--require-hashes, or--no-depswith.whl)SOURCE_DATE_EPOCH; support for SOURCE_DATE_EPOCH in sdist. pypa/setuptools#2133 (sdist ignores it, open)Linked issues
Addresses code-scanning alerts #14/#15 (Pinned-Dependencies,
pypi.yml). These are code-scanning alerts, not issues, so they close when Scorecard re-scansmain.A cross-family review has been requested in the maintainer's 007acc2 workflow, because the automated Codex review hit its quota on this PR. Please merge only after that review and with the maintainer's approval.
Reminder per AGENTS.md: no history rewrite on shared refs was performed. This branch was rebased only while its commit was local and unpushed. Backup-First exists because of the 2026-05-16 incident, in which an unverified branch delete followed by GitHub GC wiped about 20 files that the backup triple recovered.
🤖 Generated with Claude Code
https://claude.ai/code/session_017XxmGgszzaCSRtdXnEVoHB