diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c9676227..033aa3f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -589,15 +589,17 @@ jobs: uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - name: Collect the wheels + sdist + - name: Collect the wheels uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: + pattern: wheels-* path: dist merge-multiple: true - # PyPI accepts ONLY wheels + sdists; drop everything else (SBOMs, the - # Sigstore bundle, the SLSA provenance) so the upload doesn't choke. - - name: Keep only wheels + sdist in the upload dir - run: find dist -type f ! -name '*.whl' ! -name '*.tar.gz' -delete + - name: Collect the sdist + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sdist + path: dist - name: Publish to PyPI (Trusted Publishing; PEP 740 attestations on by default) uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: diff --git a/tests/release_publish_invariants.py b/tests/release_publish_invariants.py new file mode 100644 index 00000000..53994d55 --- /dev/null +++ b/tests/release_publish_invariants.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Structural release publish invariants for the PyPI upload job.""" + +from __future__ import annotations + +import json +import os +import posixpath +import shutil +import subprocess +import sys +from typing import Any + + +WORKFLOW_PATH = os.environ.get("RELEASE_WORKFLOW_PATH", ".github/workflows/release.yml") + + +def fail(message: str) -> None: + print(f"::error::release-publish invariant violated: {message}", file=sys.stderr) + raise SystemExit(1) + + +def load_workflow(path: str) -> dict[str, Any]: + try: + import yaml # type: ignore[import-not-found] + except ModuleNotFoundError: + yq = shutil.which("yq") + if yq is None: + fail("PyYAML or yq is required to parse .github/workflows/release.yml") + try: + raw = subprocess.check_output([yq, "-o=json", ".", path], text=True) + except subprocess.CalledProcessError as exc: + fail(f"{path}: yq could not parse workflow YAML: {exc}") + try: + workflow = json.loads(raw) + except json.JSONDecodeError as exc: + fail(f"{path}: yq emitted invalid JSON: {exc}") + else: + try: + with open(path, encoding="utf-8") as fh: + workflow = yaml.safe_load(fh) + except OSError as exc: + fail(f"{path}: could not read workflow: {exc}") + except Exception as exc: # PyYAML exposes several parser exception types. + fail(f"{path}: could not parse workflow YAML: {exc}") + + if not isinstance(workflow, dict): + fail(f"{path}: workflow root must be a mapping") + return workflow + + +def mapping(value: Any, context: str) -> dict[str, Any]: + if not isinstance(value, dict): + fail(f"{context} must be a mapping") + return value + + +def sequence(value: Any, context: str) -> list[Any]: + if not isinstance(value, list): + fail(f"{context} must be a sequence") + return value + + +def action_name(step: dict[str, Any]) -> str | None: + uses = step.get("uses") + if not isinstance(uses, str): + return None + return uses.split("@", 1)[0].lower() + + +def norm_path(value: Any) -> str: + if value is None: + return "" + path = str(value).strip().replace("\\", "/") + if not path: + return "" + normalized = posixpath.normpath(path) + return "" if normalized == "." else normalized.rstrip("/") + + +def boolish_true(value: Any) -> bool: + return value is True or (isinstance(value, str) and value.lower() == "true") + + +def step_label(index: int, step: dict[str, Any]) -> str: + name = step.get("name") + if isinstance(name, str) and name: + return f"step {index + 1} ({name!r})" + return f"step {index + 1}" + + +def empty(value: Any) -> bool: + return value is None or value == "" + + +def check_publish_pypi(workflow: dict[str, Any], path: str) -> None: + jobs = mapping(workflow.get("jobs"), f"{path}: jobs") + job = mapping(jobs.get("publish-pypi"), f"{path}: jobs.publish-pypi") + steps = sequence(job.get("steps"), f"{path}: jobs.publish-pypi.steps") + + publish_steps: list[tuple[int, dict[str, Any]]] = [] + artifact_downloads: list[tuple[int, dict[str, Any], dict[str, Any]]] = [] + + for index, raw_step in enumerate(steps): + step = mapping(raw_step, f"{path}: jobs.publish-pypi.steps[{index}]") + action = action_name(step) + if action == "pypa/gh-action-pypi-publish": + publish_steps.append((index, step)) + if action != "actions/download-artifact": + continue + + with_block = step.get("with", {}) + with_map = mapping(with_block, f"{path}: {step_label(index, step)} with") + artifact_downloads.append((index, step, with_map)) + + if len(publish_steps) != 1: + fail(f"{path}: publish-pypi must have exactly one pypa/gh-action-pypi-publish step") + + publish_index, publish_step = publish_steps[0] + publish_with = mapping( + publish_step.get("with", {}), f"{path}: {step_label(publish_index, publish_step)} with" + ) + if norm_path(publish_with.get("packages-dir")) != "dist": + fail(f"{path}: PyPI publish step must upload packages-dir: dist") + + wheels: list[int] = [] + sdists: list[int] = [] + for index, step, with_map in artifact_downloads: + label = step_label(index, step) + artifact_path = norm_path(with_map.get("path")) + if artifact_path != "dist": + fail( + f"{path}: {label} downloads artifacts to {artifact_path or 'the default path'!r}; " + "publish-pypi may only download wheels-* and sdist into dist" + ) + if index > publish_index: + fail(f"{path}: {label} downloads into dist after the PyPI publish step") + + name = with_map.get("name") + pattern = with_map.get("pattern") + is_wheels = ( + pattern == "wheels-*" + and empty(name) + and boolish_true(with_map.get("merge-multiple")) + ) + is_sdist = name == "sdist" and empty(pattern) + + if is_wheels: + wheels.append(index) + continue + if is_sdist: + sdists.append(index) + continue + + fail( + f"{path}: {label} downloads into dist but is not the allowed " + "'pattern: wheels-*' or 'name: sdist' artifact" + ) + + if len(wheels) != 1: + fail(f"{path}: publish-pypi must download exactly one wheels-* artifact set into dist") + if len(sdists) != 1: + fail(f"{path}: publish-pypi must download exactly one sdist artifact into dist") + + +def main() -> None: + workflow = load_workflow(WORKFLOW_PATH) + check_publish_pypi(workflow, WORKFLOW_PATH) + + +if __name__ == "__main__": + main() diff --git a/tests/release_publish_invariants.sh b/tests/release_publish_invariants.sh index 7464942f..7a99865a 100755 --- a/tests/release_publish_invariants.sh +++ b/tests/release_publish_invariants.sh @@ -2,93 +2,21 @@ # # Release-publish SBOM invariants — pinned in CI. # -# release.yml is the unified tag-triggered release pipeline; its publishes are -# gated behind GitHub Environments (Required reviewers), so the "generate a -# CycloneDX SBOM, then publish" flow runs only on a real release. A generated -# *.cdx.json SBOM once broke BOTH publish paths and would only have surfaced -# at the next release: -# * crate — the untracked SBOM dirtied the git tree, so `cargo publish` refused -# it (and would otherwise bundle it into the published .crate); -# * PyPI — the SBOM artifact was downloaded into dist/, which twine rejects. -# This pins the fixes so a regression fails here, on every push/PR, instead of -# silently passing CI and only breaking at release time. +# release.yml is the unified tag-triggered release pipeline. A generated +# *.cdx.json SBOM once dirtied the release publish flow: +# * crate — the untracked SBOM dirtied the git tree, so `cargo publish` +# refused it; +# * PyPI — non-distribution artifacts in dist/ make the PyPI upload fail. set -euo pipefail fail() { echo "::error::release-publish invariant violated: $*"; exit 1; } -# (1) Both generated SBOMs must be gitignored. A tracked/untracked *.cdx.json -# makes `cargo publish` refuse the (dirty) tree and would otherwise bundle -# the SBOM into the .crate. (Verified end-to-end when this guard was added: -# `cargo publish --dry-run` is clean with the SBOM present iff it stays -# gitignored — so this check is the durable pin.) +# Both generated SBOMs must be gitignored. A tracked/untracked *.cdx.json +# makes `cargo publish` refuse the dirty tree and would otherwise bundle +# the SBOM into the .crate. for f in ordvec.cdx.json ordvec-python/ordvec-python.cdx.json; do git check-ignore -q -- "$f" || fail "$f is not gitignored (it is a generated SBOM artifact)" done -# (2) In the PyPI publish job the step order must be: -# actions/download-artifact (pulls the SBOM into dist/) -# -> delete *.cdx.json from dist/ (either explicit cdx.json delete OR -# a keep-only-wheels/tar.gz find that excludes everything else) -# -> pypa/gh-action-pypi-publish upload. -# twine rejects a stray .cdx.json in dist/, so the cleanup must run AFTER the -# download (otherwise it is a no-op for the downloaded SBOM) and BEFORE the -# upload. The search is scoped to the `publish-pypi` job body, so a download -# step in another job cannot satisfy the ordering; the delete is matched only -# in an executing `run:` context (single-line or a `run: |` block), so a step -# name or other non-executing text cannot satisfy it; comment lines are -# skipped; and the publish step keys on the pinned action name (not the bare -# string `pypi-publish`). -wf=".github/workflows/release.yml" -[ -f "$wf" ] || fail "$wf: workflow file not found" - -# Extract the `publish-pypi` job body: from its ` publish-pypi:` key to the -# next 2-space-indented job key, or EOF. Scoping here is what makes the -# ordering meaningful — the three steps must live in the SAME job. -pub_start="$(grep -nE '^ publish-pypi:[[:space:]]*$' "$wf" | head -1 | cut -d: -f1)" -[ -n "$pub_start" ] || fail "$wf: no 'publish-pypi:' job found" -pub_end="$(awk -v s="$pub_start" 'NR>s && /^ [A-Za-z0-9_-]+:/ {print NR-1; exit}' "$wf")" -[ -n "$pub_end" ] || pub_end="$(awk 'END{print NR}' "$wf")" -job="$(sed -n "${pub_start},${pub_end}p" "$wf")" - -# First real (non-comment) line WITHIN the publish-pypi job matching the regex. -in_job() { printf '%s\n' "$job" | grep -nE "$1" | grep -vE '^[0-9]+:[[:space:]]*#' | head -1 | cut -d: -f1; } - -dl_line="$(in_job 'uses:[[:space:]]*actions/download-artifact' || true)" -# The cleanup must be a real delete in an EXECUTING `run:` context — either a -# single-line `run: ... -delete` or a line inside that step's `run: |`/`run: >` -# block. Matching the command text anywhere would also accept NON-executing text -# (a step `name:`, an `env:`/`with:` value, prose), so the delete only counts on -# a `run:` line or within a run block scalar. Accepts both forms: -# (a) explicit cdx.json delete: `find ... cdx.json ... -delete` / `rm ... *.cdx.json` -# (b) keep-only-wheels/tar.gz delete-everything: `find dist -type f ! -name '*.whl' ! -name '*.tar.gz' -delete` -# Either form removes the SBOM before the upload. -clean_line="$(printf '%s\n' "$job" | awk ' - function indent(s, i){ i = match(s, /[^ ]/); return (i ? i - 1 : length(s)) } - BEGIN { - del_a = "find.*cdx\\.json.*-delete|rm[[:space:]].*cdx\\.json" - del_b = "find.*-type[[:space:]]+f.*!.*-name.*whl.*!.*-name.*tar\\.gz.*-delete" - del = del_a "|" del_b - } - { is_comment = ($0 ~ /^[[:space:]]*#/) } - in_block { - if ($0 ~ /^[[:space:]]*$/) next # blank line stays in block - if (indent($0) > block_indent) { # block content (incl. shell # lines, - if (!is_comment && $0 ~ del) { print NR; exit } # which are literal text here, not - next # YAML comments — stay in the block) - } - in_block = 0 # dedent ends block; re-test line - } - /^[[:space:]]*run:[[:space:]]*[|>]/ { in_block = 1; block_indent = indent($0); next } - /^[[:space:]]*run:[[:space:]]/ && !is_comment { if ($0 ~ del) { print NR; exit } } -' || true)" -pub_line="$(in_job 'uses:[[:space:]]*pypa/gh-action-pypi-publish' || true)" - -[ -n "$dl_line" ] || fail "$wf (publish-pypi job): no actions/download-artifact step found" -[ -n "$clean_line" ] || fail "$wf (publish-pypi job): no step deleting *.cdx.json from dist/ (need 'find ... cdx.json ... -delete', 'rm ... *.cdx.json', or 'find ... ! -name *.whl ! -name *.tar.gz -delete')" -[ -n "$pub_line" ] || fail "$wf (publish-pypi job): no pypa/gh-action-pypi-publish step found" - -[ "$dl_line" -lt "$clean_line" ] \ - || fail "$wf (publish-pypi job): the *.cdx.json cleanup must run AFTER actions/download-artifact, else it is a no-op for the downloaded SBOM" -[ "$clean_line" -lt "$pub_line" ] \ - || fail "$wf (publish-pypi job): the *.cdx.json cleanup must run BEFORE the pypa publish" +python3 tests/release_publish_invariants.py echo "OK: release-publish SBOM invariants hold."