Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ 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: 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: >-
Expand All @@ -156,9 +161,10 @@ jobs:
environment: pypi
permissions:
actions: read
contents: write
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
Expand Down Expand Up @@ -225,6 +231,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 }}
Expand All @@ -245,8 +265,21 @@ 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
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:
- name: Create the source GitHub Release
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.build.outputs.release_tag }}
run: |
Expand Down
149 changes: 149 additions & 0 deletions tests/test_publish_workflow_isolation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""Contract for .github/workflows/publish.yml (#240).

Only the publish job can mint a PyPI OIDC token, and it runs no project code:
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

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] == [
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 }}"
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]

Check warning on line 96 in tests/test_publish_workflow_isolation.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Split this composite assertion into separate assertions.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_sdk-python&issues=AaGj2_Mo1zV7SpaJ_Ywt&open=AaGj2_Mo1zV7SpaJ_Ywt&pullRequest=241
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]

Check warning on line 125 in tests/test_publish_workflow_isolation.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Split this composite assertion into separate assertions.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_sdk-python&issues=AaGj2_Mo1zV7SpaJ_Ywu&open=AaGj2_Mo1zV7SpaJ_Ywu&pullRequest=241
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

Check warning on line 128 in tests/test_publish_workflow_isolation.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Split this composite assertion into separate assertions.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_sdk-python&issues=AaGj2_Mo1zV7SpaJ_Ywv&open=AaGj2_Mo1zV7SpaJ_Ywv&pullRequest=241


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"}
# 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
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]

Check warning on line 143 in tests/test_publish_workflow_isolation.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Split this composite assertion into separate assertions.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_sdk-python&issues=AaGj2_Mo1zV7SpaJ_Yww&open=AaGj2_Mo1zV7SpaJ_Yww&pullRequest=241


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))
Loading