Skip to content

ci(release): hash-lock the release toolchain, dry-run and verify reproducible builds (code-scanning #14/#15) - #170

Merged
marcohost33-maker merged 9 commits into
mainfrom
claude/liouscope-next-steps-rg7amo
Sep 30, 2026
Merged

marcohost33-maker merged 9 commits into
mainfrom
claude/liouscope-next-steps-rg7amo

Conversation

@marcohost33-maker

@marcohost33-maker marcohost33-maker commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Summary

The release job that builds the distribution PyPI receives installed its toolchain with python -m pip install --upgrade pip and pip 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 two pypi.yml lines 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 with pip-compile --allow-unsafe --generate-hashes --strip-extras under 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:
    • The unpinned pip upgrade is removed rather than pinned. The bundled pip already supports hash-checking mode.
    • The build runs with 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 new pip entry for /.github/requirements has 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, and pytest runs it through tests/test_release_pin_gate.py. It rejects each of the following:
    • a release-workflow pip install that is neither --require-hashes -r <lock> nor --no-deps with local *.whl paths only. The check covers pip global options placed before install, and fails closed on spellings it cannot parse, such as uv pip install, sudo pip install and "$PY" -m pip install.
    • a lock entry without an exact == pin or without a well-formed digest, and nested -r/-c/-e lines.
    • a lock that misses a top-level requirement of its .in.
    • a lock compiled for a different Python than the workflow's python-version.
    • a locked backend that falls below [build-system] requires.
    • a header regeneration command that lacks --generate-hashes or carries --no-index. See the negative results below.
    • a workflow that publishes to PyPI but is missing from RELEASE_WORKFLOWS.
    • an inline YAML value that the parser would reject. See the negative results below.

Round 2 (429ecf8): the release path is exercised before release day and proven reproducible

  • Release dry-run. The build job of pypi.yml now also runs on every pull request and every push to main, 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. Both release runs for v0.5.0 failed that way, there on the Trusted Publisher, which was not yet registered.
    • Only publish can upload, and it still runs only for a published release behind PYPI_PUBLISH_ENABLED.
    • The build job has no id-token and no environment.
    • Superseded PR dry-runs are cancelled. Release runs never are.
  • Reproducibility is verified on every run. The job 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 before the change: without the variable, neither artifact repeated.
    • With the variable, the wheel was byte-identical. The sdist still differed in its gzip header, member mtimes and owner fields, because setuptools' sdist ignores SOURCE_DATE_EPOCH (pypa/setuptools#2133, open). The comparison disregards exactly those fields and nothing else.
    • The SHA-256 of each artifact goes to the job summary. Dry-run artifacts are kept for 7 days, release artifacts for 90.
  • Gate additions:
    • every release.in specifier must hold for the locked version. Coverage by name alone accepted build<1.6 against a lock pinning 1.6.1;
    • markers, option lines and direct references in release.in are refused;
    • uv/poetry/flit/hatch/pdm publish commands are recognised.
  • Contract test updated. tests/test_pypi_workflow_contract.py pinned the old "Manual dry-run" wording. It now asserts the new message plus three new contracts:
    • the dry-run triggers reach only the build job;
    • release runs cannot be cancelled;
    • the reproducibility step runs before the QA gate and before the upload.
  • Docs. docs/DEVELOPMENT_MIGRATION_0.6.0.dev0.md describes the release workflow and is updated to match.

No runtime code, anchors, reported numbers or run-manifest fields change.

Scope

  • Bug fix (no new feature)
  • New diagnostic or layer (please link the motivating reference)
  • Performance / sparse-path / numerics improvement
  • Docs / examples / packaging only
  • CI / workflow / security
  • Release / packaging / publish evidence

Verification

  • pytest -q passes locally: 1292 passed at 429ecf8, exit 0 (Python 3.11, on main 87e9539)
  • tests/test_anchors.py unchanged (21/21)
  • If touching MANIFEST_SCHEMA.json: n/a, not touched
  • If new external dependency: n/a. No runtime or dev dependency is added. The lock covers only the CI release job.
  • If touching .github/workflows/: every action remains full-SHA pinned (check_workflow_hardening.py green), token permissions are unchanged, zizmor 1.30.1 reports no findings on pypi.yml and quality-contract.yml, and actionlint 1.7.12 reports nothing on all 10 workflows.
  • If touching claims/docs/release wording: the CHANGELOG ### Security entry states only measured facts. No release, PyPI or DOI status claim is made.
  • If touching branch protection / required checks: no job names change. The three new steps sit inside the existing, unconditional quality contract job and its (head) job. pypi.yml is not a required workflow, so its new pull_request trigger cannot strand a required check. check_agent_branch_triggers.py is green.

Evidence (local, round 1 on 2026-09-25, round 2 on 2026-09-26)

  • Release simulation on git archive of bfbc211, in a fresh Python 3.12 venv. 21caa3d only changes a comment in release.in:
    • the hash-locked install succeeds and pip check finds no broken requirements;
    • python -m build --no-isolation succeeds;
    • twine check --strict passes for both the wheel and the sdist;
    • check-wheel-contents reports OK.
  • Isolation equivalence: the --no-isolation wheel is file-for-file identical (diff -r of 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, plus cffi's cffi_modules keyword, which this project does not use.
  • Negative control: the gate run on main's previous pypi.yml rejects exactly lines 27 and 28 (the two flagged installs) and reports that no hash-locked install exists.
  • Mutation probe: 22 of 22 mutants of the gate are killed by the 21 adversarial unit tests, and the unmutated control stays green. The probe first ran in a temp directory where the repository-self-test could not find the repo. That made every mutant "killed" for the wrong reason. The layout was fixed and the probe re-run before these numbers were taken.
  • Scanner vs. PyYAML: on all 10 workflows the stdlib plain-scalar scanner and PyYAML agree.
  • Cross-interpreter check: the gate and its unit suite pass on Python 3.12 and 3.13. On 3.10 the gate fails closed with an explicit message, because tomllib is 3.11+. The pytest wrapper is skipped there with that reason.
  • Round-2 evidence:
    • The build job's run blocks were extracted from pypi.yml with PyYAML and executed in a fresh clone. They pass on the pull_request path and on the release path with a matching tag, and fail at the identity step on a wrong tag.
    • actionlint 1.7.12 reports nothing on all 10 workflows, and zizmor 1.30.1 has no findings on the changed ones.
    • Mutation probe over both scripts: 38 of 38 mutants killed, and all three controls stay green.
  • Cross-machine reproducibility on GitHub. The first real PR dry-run (run 36274475580, merge commit f9f4a83) built the wheel 894ca37a…a8be1afd on 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.
  • The published release checks out. The PyPI files of 0.5.0 match their recorded SHA-256. A rebuild from tag v0.5.0 reproduces every packaged file of the wheel and all 103 sdist members. The only difference is the wheel's Generator: setuptools (83.0.0) line versus today's 84.0.0, plus RECORD, 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

  1. My own first draft broke the release workflow, and CI would not have noticed. The first draft wrote 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 a WARN and 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.
  2. pip-tools 7.6.1 with click ≥ 8.3 writes a spurious --no-index into 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 with click<8.3, and the pins and hashes were byte-identical. release.in documents the caveat, and the gate rejects such a header.
  3. Correction, made after a web search. A first version of the release.in comment 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-unsafe from the lock's content and takes only the interpreter from the header's with Python X.Y. Commit 21caa3d corrects the comment. If a Dependabot refresh ever resolves for a different Python, the gate's python-version check turns that update red rather than letting wrongly resolved markers through. I have not tested this live.
  4. A contract test I did not look for. tests/test_pypi_workflow_contract.py already pins parts of pypi.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, search tests/ for its file name.

Deliberately out of scope

  • The test workflows (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.
  • zizmor 1.30.1 also reports one pre-existing self-repository finding (low) in the untouched ci-reusable-pilot.yml. It is newer than the zizmor pinned in CI and is not part of this change.
  • actions/upload-artifact/download-artifact v4 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 at 429ecf8, exit 0
  • pytest tests/test_anchors.py -v: 21/21
  • ruff check src tests benchmarks: clean
  • mypy src/liouscope: no issues in 55 source files
  • python 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) and test_agent_branch_triggers.py: OK
  • Release simulation from the lock (details above)
  • Mutation probe: 38/38 killed over both scripts, with the controls green
  • Build-job simulation from the YAML's own run blocks: the pull_request and release paths pass, a wrong tag fails
  • Required CI green on this PR at head 429ecf8, including the new pypi.yml dry-run: 38 check runs, 34 succeeded, 4 skipped (3 template placeholders and publish, which is correct for a PR), 0 failed. The dry-run build job took 20 s.
  • Cross-environment reproducibility: the CI wheel of merge commit f9f4a83 (runner, Python 3.12.14) has SHA-256 894ca37a888c7b439c25d4874af6b7304b0df8727e92ebce2ed42bd2a8be1afd, and a local rebuild of the same commit on a different machine (Python 3.12.3) produces the identical hash

Quality contract

  • This PR does not introduce unsupported production-ready, externally certified, PyPI-published, DOI/Zenodo-archived, or release-complete wording
  • This PR preserves the research / pre-clinical disclaimer
  • False-pass and rework risk considered, not only green CI. Every rejection is paired with a positive control that differs in one aspect. The mutation probe was run with its own control. The YAML scanner was cross-checked against a real parser.

Reproducibility note

No numerical result changes. Lock regeneration: from .github/requirements/, with Python 3.12, pip-tools 7.6.1 and click<8.3, run pip-compile --allow-unsafe --generate-hashes --strip-extras release.in.

References:

  • pip, Secure installs / hash-checking mode (pip.pypa.io)
  • pip-tools --generate-hashes
  • OpenSSF Scorecard checks/raw/shell_download_validate.go (isUnpinnedPipInstall: --require-hashes, or --no-deps with .whl)
  • dependabot-core #7740, #14137
  • PyPA Packaging User Guide, Publishing package distribution releases using GitHub Actions CI/CD workflows: build on every push, publish only on tags
  • reproducible-builds.org, 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-scans main.

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

#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
@marcohost33-maker marcohost33-maker added the agent:claude PR authored by Claude Code label Sep 25, 2026 — with Claude
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
@marcohost33-maker
marcohost33-maker marked this pull request as ready for review September 25, 2026 21:17
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

…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
@marcohost33-maker marcohost33-maker changed the title ci(release): hash-lock the release toolchain and gate it (code-scanning #14/#15) ci(release): hash-lock the release toolchain, dry-run and verify reproducible builds (code-scanning #14/#15) Sep 26, 2026
@marcohost33-maker
marcohost33-maker marked this pull request as draft September 26, 2026 21:55
@marcohost33-maker
marcohost33-maker marked this pull request as ready for review September 26, 2026 22:01

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread .github/scripts/compare_dists.py
Comment thread .github/workflows/pypi.yml Outdated
Comment thread .github/scripts/check_release_pins.py
Comment thread .github/scripts/check_release_pins.py Outdated
Comment thread .github/scripts/check_release_pins.py Outdated
Comment thread .github/scripts/check_release_pins.py Outdated
marcohost33-maker and others added 6 commits September 30, 2026 11:49
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>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@marcohost33-maker
marcohost33-maker merged commit c0e5dab into main Sep 30, 2026
40 checks passed
@marcohost33-maker
marcohost33-maker deleted the claude/liouscope-next-steps-rg7amo branch September 30, 2026 10:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent:claude PR authored by Claude Code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants