From b8917a36f3aa49266517e943f5512c0084a3b744 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:59:57 -0600 Subject: [PATCH 1/2] Isolate the PyPI publish job from post-publish verification 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 --- .github/workflows/publish.yml | 34 ++++-- tests/test_publish_workflow_isolation.py | 141 +++++++++++++++++++++++ 2 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 tests/test_publish_workflow_isolation.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ed9a14d8..c0d0d918 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -147,6 +147,10 @@ jobs: path: dist-handoff.tar publish: + # The only job that can mint a PyPI OIDC token. It runs no project code, + # no build and no install: it downloads the build job's artifact, checks + # its digest, extracts it and uploads it. Verification and the GitHub + # Release run in later jobs without id-token. needs: build runs-on: ubuntu-latest if: >- @@ -156,15 +160,8 @@ jobs: environment: pypi permissions: actions: read - contents: write id-token: write steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - fetch-depth: 0 - persist-credentials: false - ref: ${{ github.sha }} - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: artifact-ids: ${{ needs.build.outputs.artifact-id }} @@ -225,6 +222,20 @@ jobs: print-hash: true skip-existing: true + verify: + # Post-publish checks run after publication, with read-only contents and no + # OIDC, and fail the workflow on any mismatch. + needs: [build, publish] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + - name: Verify exact PyPI JSON with an advisory rendered-page audit env: RELEASE_SOURCE_SHA: ${{ needs.build.outputs.release_commit }} @@ -245,8 +256,17 @@ jobs: --attempts 30 --interval-seconds 10 + github-release: + # Creates the source GitHub Release only after verification passes. It + # runs only the preinstalled GitHub CLI: no checkout and no project code. + needs: [build, verify] + runs-on: ubuntu-latest + permissions: + contents: write + steps: - name: Create the source GitHub Release env: + GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.build.outputs.release_tag }} run: | diff --git a/tests/test_publish_workflow_isolation.py b/tests/test_publish_workflow_isolation.py new file mode 100644 index 00000000..79cf0941 --- /dev/null +++ b/tests/test_publish_workflow_isolation.py @@ -0,0 +1,141 @@ +"""Contract for .github/workflows/publish.yml (#240). + +Only the publish job can mint a PyPI OIDC token, and it runs no project code: +it downloads the build job's artifact, checks its digest, extracts it and +uploads it. Post-publish verification runs in a later job without id-token, +and the source GitHub Release is created by a minimal job that runs only the +preinstalled GitHub CLI. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[1] +PUBLISH_WORKFLOW = ROOT / ".github" / "workflows" / "publish.yml" + +PYPI_PUBLISH_ACTION = "pypa/gh-action-pypi-publish@" +DOWNLOAD_ARTIFACT_ACTION = "actions/download-artifact@" +CHECKOUT_ACTION = "actions/checkout@" + +# Commands that would run project code, a build backend or an installer. +PROJECT_CODE = re.compile( + r"(^|[\s;&|(])(python3?|pip3?|pipx|uv|uvx|twine|hatch|poetry|tox|nox|make)(\s|$)" + r"|python3?\s+-m\s+(build|pip)|\bscripts/|\bsetup\.py\b|\bpyproject\.toml\b", +) + + +def workflow() -> dict[str, Any]: + document = yaml.safe_load(PUBLISH_WORKFLOW.read_text(encoding="utf-8")) + assert isinstance(document, dict) + return document + + +def jobs() -> dict[str, dict[str, Any]]: + value = workflow()["jobs"] + assert isinstance(value, dict) + return value + + +def needs(job: dict[str, Any]) -> list[str]: + value = job.get("needs", []) + return [value] if isinstance(value, str) else list(value) + + +def uses(job: dict[str, Any]) -> list[str]: + return [step["uses"] for step in job.get("steps", []) if "uses" in step] + + +def runs(job: dict[str, Any]) -> list[str]: + return [step["run"] for step in job.get("steps", []) if "run" in step] + + +def test_the_workflow_grants_no_token_by_default() -> None: + assert workflow()["permissions"] == {"contents": "read"} + + +def test_only_the_publish_job_can_mint_an_oidc_token() -> None: + minting = {name for name, job in jobs().items() if (job.get("permissions") or {}).get("id-token") == "write"} + assert minting == {"publish"} + assert "id-token" not in workflow()["permissions"] + + +def test_the_publish_job_has_exactly_the_permissions_publication_needs() -> None: + publish = jobs()["publish"] + assert publish["permissions"] == {"actions": "read", "id-token": "write"} + assert publish["environment"] == "pypi" + assert needs(publish) == ["build"] + + +def test_the_publish_job_only_downloads_checks_and_uploads() -> None: + publish = jobs()["publish"] + actions = uses(publish) + assert [action.split("@")[0] + "@" for action in actions] == [DOWNLOAD_ARTIFACT_ACTION, PYPI_PUBLISH_ACTION] + assert all(re.fullmatch(r"[^@]+@[0-9a-f]{40}", action) for action in actions) + # The artifact is downloaded by its recorded id, with digest enforcement. + download = next(step for step in publish["steps"] if step.get("uses", "").startswith(DOWNLOAD_ARTIFACT_ACTION)) + assert download["with"]["digest-mismatch"] == "error" + assert download["with"]["artifact-ids"] == "${{ needs.build.outputs.artifact-id }}" + # No checkout: the job never sees the project source. + assert not any(action.startswith(CHECKOUT_ACTION) for action in actions) + scripts = runs(publish) + assert len(scripts) == 2 + for script in scripts: + assert PROJECT_CODE.search(script) is None, script + assert "sha256sum" in scripts[0] and "EXPECTED_ARTIFACT_DIGEST" in scripts[0] + assert scripts[1].split() == ["mkdir", "dist", "tar", "-xf", "isolated-python-dist/dist-handoff.tar", "-C", "dist"] + + +@pytest.mark.parametrize("script", [ + "pip install .", "python -m build", "python scripts/check_release_metadata.py", "pip install --dry-run x", + "twine upload dist/*", "uv build", "cd x && python3 -m pip install -e .", +]) +def test_the_project_code_pattern_catches_installs_and_builds(script: str) -> None: + assert PROJECT_CODE.search(script) is not None + + +def test_the_pypi_publish_action_runs_only_in_the_publish_job() -> None: + for name, job in jobs().items(): + if name != "publish": + assert not any(action.startswith(PYPI_PUBLISH_ACTION) for action in uses(job)), name + assert job.get("environment") is None, name + + +def test_post_publish_verification_runs_after_publication_without_oidc() -> None: + verify = jobs()["verify"] + assert verify["permissions"] == {"contents": "read"} + assert set(needs(verify)) == {"build", "publish"} + checkout = next(step for step in verify["steps"] if step.get("uses", "").startswith(CHECKOUT_ACTION)) + assert checkout["with"]["persist-credentials"] is False + scripts = runs(verify) + # The same checks, in the same order: exact PyPI JSON, then the project + # surface and its supported installs. + assert len(scripts) == 2 + assert "scripts/check_release_metadata.py" in scripts[0] and "--pypi-version" in scripts[0] + assert "scripts/check_pypi_project_surface.py" in scripts[1] + for script in scripts: + assert "--attempts 30" in script and "--interval-seconds 10" in script + + +def test_the_github_release_follows_verification_and_runs_no_third_party_code() -> None: + release = jobs()["github-release"] + assert release["permissions"] == {"contents": "write"} + assert set(needs(release)) == {"build", "verify"} + assert uses(release) == [] + scripts = runs(release) + assert len(scripts) == 1 + commands = {line.split()[0] for line in scripts[0].splitlines() if line.strip()} + assert commands <= {"if", "gh", "arguments=(--verify-tag", "arguments+=(--prerelease)", "fi"} + assert PROJECT_CODE.search(scripts[0]) is None + assert "gh release create" in scripts[0] and "--verify-tag" in scripts[0] + + +def test_the_build_job_holds_no_publication_credential() -> None: + build = jobs()["build"] + assert "permissions" not in build + assert not any(action.startswith(PYPI_PUBLISH_ACTION) for action in uses(build)) From 577c354468a2b504eb002dd78313b20c57576b88 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:09:44 -0600 Subject: [PATCH 2/2] Satisfy the central policy for the isolated publish and release jobs The central action policy preflight failed on b8917a36: - 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 --- .github/workflows/publish.yml | 19 ++++++++++++++++--- tests/test_publish_workflow_isolation.py | 22 +++++++++++++++------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c0d0d918..43c9b715 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -148,9 +148,10 @@ jobs: publish: # The only job that can mint a PyPI OIDC token. It runs no project code, - # no build and no install: it downloads the build job's artifact, checks - # its digest, extracts it and uploads it. Verification and the GitHub - # Release run in later jobs without id-token. + # no build and no install: after the policy's credential-free checkout it + # downloads the build job's artifact, checks its digest, extracts it and + # uploads it. Verification and the GitHub Release run in later jobs + # without id-token. needs: build runs-on: ubuntu-latest if: >- @@ -162,6 +163,14 @@ jobs: actions: read id-token: write steps: + # The central action policy's fixed preamble for a privileged artifact + # consumer. Nothing in this job executes the checked-out source. + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: artifact-ids: ${{ needs.build.outputs.artifact-id }} @@ -261,6 +270,10 @@ jobs: # runs only the preinstalled GitHub CLI: no checkout and no project code. needs: [build, verify] runs-on: ubuntu-latest + if: >- + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || + (github.event_name == 'workflow_dispatch' && + github.ref == 'refs/heads/main' && inputs.publish) permissions: contents: write steps: diff --git a/tests/test_publish_workflow_isolation.py b/tests/test_publish_workflow_isolation.py index 79cf0941..a880591c 100644 --- a/tests/test_publish_workflow_isolation.py +++ b/tests/test_publish_workflow_isolation.py @@ -1,10 +1,11 @@ """Contract for .github/workflows/publish.yml (#240). Only the publish job can mint a PyPI OIDC token, and it runs no project code: -it downloads the build job's artifact, checks its digest, extracts it and -uploads it. Post-publish verification runs in a later job without id-token, -and the source GitHub Release is created by a minimal job that runs only the -preinstalled GitHub CLI. +after the central policy's credential-free checkout it downloads the build +job's artifact, checks its digest, extracts it and uploads it. Post-publish +verification runs in a later job without id-token, and the source GitHub +Release is created by a minimal job that runs only the preinstalled GitHub +CLI. """ from __future__ import annotations @@ -75,14 +76,19 @@ def test_the_publish_job_has_exactly_the_permissions_publication_needs() -> None def test_the_publish_job_only_downloads_checks_and_uploads() -> None: publish = jobs()["publish"] actions = uses(publish) - assert [action.split("@")[0] + "@" for action in actions] == [DOWNLOAD_ARTIFACT_ACTION, PYPI_PUBLISH_ACTION] + assert [action.split("@")[0] + "@" for action in actions] == [ + CHECKOUT_ACTION, DOWNLOAD_ARTIFACT_ACTION, PYPI_PUBLISH_ACTION, + ] assert all(re.fullmatch(r"[^@]+@[0-9a-f]{40}", action) for action in actions) + # The central action policy's fixed preamble for a privileged artifact + # consumer: a credential-free checkout that nothing in the job executes. + assert publish["steps"][0] == {"uses": actions[0], "with": { + "fetch-depth": 0, "persist-credentials": False, "ref": "${{ github.sha }}", + }} # The artifact is downloaded by its recorded id, with digest enforcement. download = next(step for step in publish["steps"] if step.get("uses", "").startswith(DOWNLOAD_ARTIFACT_ACTION)) assert download["with"]["digest-mismatch"] == "error" assert download["with"]["artifact-ids"] == "${{ needs.build.outputs.artifact-id }}" - # No checkout: the job never sees the project source. - assert not any(action.startswith(CHECKOUT_ACTION) for action in actions) scripts = runs(publish) assert len(scripts) == 2 for script in scripts: @@ -126,6 +132,8 @@ def test_the_github_release_follows_verification_and_runs_no_third_party_code() release = jobs()["github-release"] assert release["permissions"] == {"contents": "write"} assert set(needs(release)) == {"build", "verify"} + # Privileged, so it runs only where publication itself may run. + assert release["if"] == jobs()["publish"]["if"] assert uses(release) == [] scripts = runs(release) assert len(scripts) == 1