Skip to content

Isolate the PyPI publish job from post-publish verification - #241

Merged
rmcdaniel merged 2 commits into
mainfrom
fix/240-isolate-pypi-publish-job
Nov 4, 2026
Merged

rmcdaniel merged 2 commits into
mainfrom
fix/240-isolate-pypi-publish-job

Conversation

@rmcdaniel

@rmcdaniel rmcdaniel commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Fixes #240.

Problem

The publish job held job-wide id-token: write and contents: write. After publishing, it checked out the source and ran check_release_metadata.py and check_pypi_project_surface.py, which includes a pip install --dry-run against PyPI, while a PyPI OIDC token could still be minted. A step is not a boundary; a job is.

Change (.github/workflows/publish.yml)

build is unchanged.

Job Needs Permissions Runs
publish build actions: read, id-token: write (environment pypi) The central policy's fixed preamble for a privileged artifact consumer: a credential-free checkout (fetch-depth: 0, persist-credentials: false, ref: github.sha) that nothing executes. Then pinned download-artifact by artifact id with digest-mismatch: error, the existing producer digest and identity validation, the tar extraction, and pinned pypa/gh-action-pypi-publish. No project code, no build or install.
verify build, publish contents: read Checkout of the same commit (persist-credentials: false), then the same two checks in the same order: exact PyPI JSON (--pypi-version), then the canonical project surface and supported installs. Both keep --attempts 30 --interval-seconds 10, and a mismatch fails the workflow.
github-release build, verify; same if as publish (tag push, or publish dispatch from main) contents: write The unchanged gh release view/create script, with GH_REPO in place of a checkout. No uses: steps and no project code. As before, the release is created only after verification passes.

Behaviour preserved:

  • In build-only runs, publish is skipped, so verify and github-release are skipped too.
  • On a verification failure there is no GitHub Release, as before.
  • publish no longer has contents: write. Nothing it still runs used that permission.
  • Central action policy: the policy requires the credential-free checkout before a privileged download, and requires a privileged job to run only from main; this matches both. A local run of qualification_policy.py validate passes.

Contract test (tests/test_publish_workflow_isolation.py, 15 cases)

  • The workflow default is contents: read, and only publish has id-token: write.
  • publish has exactly actions: read and id-token: write, in environment pypi, and needs only build.
  • What publish may run:
    • its only actions are the SHA-pinned download-artifact and PyPI publish actions;
    • the download is by the recorded artifact id with digest enforcement;
    • its first step is exactly the policy's credential-free checkout;
    • its two run: scripts are the digest validation and the extraction.
    • Neither may invoke python, pip, uv, twine, hatch, poetry, tox, nox, make, python -m build/pip, a scripts/ file, setup.py or pyproject.toml. Positive controls show the pattern catches each install and build shape.
  • The PyPI publish action and the pypi environment appear in no other job.
  • verify has contents: read, needs publish, and runs the two checks in order with their retry settings, from a checkout without persisted credentials.
  • github-release has contents: write, needs verify, has publish's condition, uses no action, and runs only the gh script.
  • Fail-before: against main's workflow, 4 of the 15 cases fail.

Local checks (Python 3.13)

  • ruff check src/ tests/: clean.
  • mypy src/durable_workflow/: no issues.
  • Corpus validator: pass.
  • Full unit suite: 4,780 passed, 3 skipped, 8 failed. The same 8 fail on unmodified main: TLS fixture certificates rejected by the host's OpenSSL, and two boundary-scanner tests tied to the host git identity.
  • qualification_policy.py validate from durable-workflow/.github main: passes. The first head, b8917a36, failed the hosted preflight on the two rules above.
  • actionlint and zizmor aren't installed here. The workflow was parse-checked with PyYAML; hosted CI and the central action-policy check validate it further.

The publish workflow was not dispatched.

🤖 Generated with Claude Code

The publish job held job-wide id-token: write and contents: write, and
after publishing it checked out the source and ran the check_* scripts,
including a pip install --dry-run against PyPI, while OIDC was still
mintable. A step is not a boundary; a job is.

- publish: downloads the build artifact by id with digest enforcement,
  validates the producer digest and identity, extracts it and uploads it.
  Permissions are actions: read and id-token: write only. No checkout,
  no project code, no build or install.
- verify (needs build, publish): contents: read, no id-token. Checks out
  the same commit without persisted credentials and runs the same two
  checks in the same order (exact PyPI JSON, then the project surface
  and supported installs). A mismatch fails the workflow.
- github-release (needs build, verify): contents: write. Runs only the
  preinstalled GitHub CLI, with GH_REPO in place of a checkout, and is
  created only after verification passes, as before.

tests/test_publish_workflow_isolation.py pins the contract: only the
publish job can mint OIDC, it uses only the pinned download and PyPI
publish actions and runs no python, pip, uv, twine, build or project
script, verification follows publication without OIDC, and the release
job runs no third-party action or project code.

Fixes #240

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The central action policy preflight failed on b8917a3:

- A privileged artifact consumer must open with the policy's fixed,
  credential-free checkout (fetch-depth 0, persist-credentials false,
  ref github.sha) before the digest-bound download. The publish job
  keeps that exact step; nothing in the job executes the checkout.
- A privileged workflow_dispatch job must not run outside main. The
  github-release job (contents: write) now has the publish job's
  condition: a tag push, or a publish dispatch from main.

The contract test pins both. The local run of the central
qualification_policy.py validate now passes.

Refs #240

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Nov 4, 2026

Copy link
Copy Markdown

@rmcdaniel

Copy link
Copy Markdown
Member Author

General interim review (Security unavailable): exact head 577c354468a2b504eb002dd78313b20c57576b88 is CLEARED for merge.

The diff is 2 files (publish.yml +34/−1, plus a new isolation contract test).

  • publish: the only job with id-token: write (alongside actions: read, environment pypi). It has the policy's credential-free checkout of github.sha, which nothing executes, then a download by artifact id, a digest/identity validation, an extract and pypa/gh-action-pypi-publish. There's no build, install or project script.
  • verify: contents: read, no OIDC. It runs the two existing post-publish checks after publish, and fails the workflow on a mismatch.
  • github-release: contents: write, no checkout and no actions; only the preinstalled gh, gated like publish (tag push or a main dispatch).
  • The contract test (15 cases, 4 fail on main) pins these properties. CI is all green.

Non-blocking follow-up: the publish job's tar -xf trusts member paths from the build artifact. That's not a credential path (the artifact is the build's output anyway), but hardening is cheap: list the members and require only *.whl/*.tar.gz regular files with no / or .., and extract with --no-same-owner --no-same-permissions.

Fixes #240. Merging by General.

@rmcdaniel
rmcdaniel marked this pull request as ready for review November 4, 2026 00:17
@rmcdaniel
rmcdaniel merged commit 407f1ed into main Nov 4, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Move post-publish checks out of the PyPI publish job (MEDIUM-LOW)

1 participant