From 771ff9e146b87a61631c916e47a5ffc8e21f7ee7 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Thu, 28 May 2026 10:17:42 -0500 Subject: [PATCH 1/3] ci(release): target PyPI artifact downloads Signed-off-by: Nelson Spence --- .github/workflows/release.yml | 12 ++-- tests/release_publish_invariants.sh | 104 ++++++++++------------------ 2 files changed, 44 insertions(+), 72 deletions(-) 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.sh b/tests/release_publish_invariants.sh index 7464942f..4516d038 100755 --- a/tests/release_publish_invariants.sh +++ b/tests/release_publish_invariants.sh @@ -2,93 +2,63 @@ # # 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); +# release.yml is the unified tag-triggered release pipeline. A generated +# *.cdx.json SBOM once broke BOTH publish paths: +# * crate — the untracked SBOM dirtied the git tree, so `cargo publish` +# refused it; # * 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. +# This guard keeps SBOMs ignored and ensures the PyPI publish job only downloads +# Python distributables into dist/ in the first place. 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.) +# 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`). +# (2) PyPI accepts only wheels + sdists. The publish-pypi job must therefore use +# targeted artifact downloads for `wheels-*` and `sdist`; broad downloads +# would also pull SBOM / provenance / Sigstore artifacts into dist/. 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; } +printf '%s\n' "$job" | grep -Eq 'uses:[[:space:]]*actions/download-artifact@' \ + || fail "$wf (publish-pypi job): no actions/download-artifact steps found" +printf '%s\n' "$job" | grep -Eq 'pattern:[[:space:]]*wheels-\*[[:space:]]*$' \ + || fail "$wf (publish-pypi job): no targeted wheels-* artifact download found" +printf '%s\n' "$job" | grep -Eq 'name:[[:space:]]*sdist[[:space:]]*$' \ + || fail "$wf (publish-pypi job): no targeted sdist artifact download found" +printf '%s\n' "$job" | grep -Eq 'uses:[[:space:]]*pypa/gh-action-pypi-publish@' \ + || fail "$wf (publish-pypi job): no pypa/gh-action-pypi-publish step found" -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) +# Guard against reintroducing the old broad download shape: +# uses: actions/download-artifact +# with: +# path: dist +# merge-multiple: true +# and no `name:` or `pattern:` selector in that same step. +printf '%s\n' "$job" | awk ' + function flush() { + if (in_download && has_dist && !has_selector) { + print "broad"; exit 0 } - in_block = 0 # dedent ends block; re-test line + in_download = has_dist = has_selector = 0 } - /^[[: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" + /^[[:space:]]*-[[:space:]]/ { flush() } + /uses:[[:space:]]*actions\/download-artifact@/ { in_download = 1 } + in_download && /^[[:space:]]*path:[[:space:]]*dist[[:space:]]*$/ { has_dist = 1 } + in_download && /^[[:space:]]*(name|pattern):[[:space:]]*/ { has_selector = 1 } + END { flush() } +' | grep -q '^broad$' \ + && fail "$wf (publish-pypi job): broad artifact download into dist/ would include SBOM/provenance assets" echo "OK: release-publish SBOM invariants hold." From fe0f4a586f9fc71163bb83e800eb64f03af4bdf1 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Thu, 28 May 2026 11:10:18 -0500 Subject: [PATCH 2/3] test: remove release workflow yaml parsing Signed-off-by: Nelson Spence --- tests/release_publish_invariants.sh | 51 +++-------------------------- 1 file changed, 4 insertions(+), 47 deletions(-) diff --git a/tests/release_publish_invariants.sh b/tests/release_publish_invariants.sh index 4516d038..e3547d45 100755 --- a/tests/release_publish_invariants.sh +++ b/tests/release_publish_invariants.sh @@ -3,62 +3,19 @@ # Release-publish SBOM invariants — pinned in CI. # # release.yml is the unified tag-triggered release pipeline. A generated -# *.cdx.json SBOM once broke BOTH publish paths: +# *.cdx.json SBOM once dirtied the crate publish tree: # * crate — the untracked SBOM dirtied the git tree, so `cargo publish` # refused it; -# * PyPI — the SBOM artifact was downloaded into dist/, which twine rejects. -# This guard keeps SBOMs ignored and ensures the PyPI publish job only downloads -# Python distributables into dist/ in the first place. +# PyPI artifact selection is owned by the workflow itself; this guard stays +# deliberately narrow so CI does not become a fragile YAML parser. set -euo pipefail fail() { echo "::error::release-publish invariant violated: $*"; exit 1; } -# (1) Both generated SBOMs must be gitignored. A tracked/untracked *.cdx.json +# 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) PyPI accepts only wheels + sdists. The publish-pypi job must therefore use -# targeted artifact downloads for `wheels-*` and `sdist`; broad downloads -# would also pull SBOM / provenance / Sigstore artifacts into dist/. -wf=".github/workflows/release.yml" -[ -f "$wf" ] || fail "$wf: workflow file not found" - -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")" - -printf '%s\n' "$job" | grep -Eq 'uses:[[:space:]]*actions/download-artifact@' \ - || fail "$wf (publish-pypi job): no actions/download-artifact steps found" -printf '%s\n' "$job" | grep -Eq 'pattern:[[:space:]]*wheels-\*[[:space:]]*$' \ - || fail "$wf (publish-pypi job): no targeted wheels-* artifact download found" -printf '%s\n' "$job" | grep -Eq 'name:[[:space:]]*sdist[[:space:]]*$' \ - || fail "$wf (publish-pypi job): no targeted sdist artifact download found" -printf '%s\n' "$job" | grep -Eq 'uses:[[:space:]]*pypa/gh-action-pypi-publish@' \ - || fail "$wf (publish-pypi job): no pypa/gh-action-pypi-publish step found" - -# Guard against reintroducing the old broad download shape: -# uses: actions/download-artifact -# with: -# path: dist -# merge-multiple: true -# and no `name:` or `pattern:` selector in that same step. -printf '%s\n' "$job" | awk ' - function flush() { - if (in_download && has_dist && !has_selector) { - print "broad"; exit 0 - } - in_download = has_dist = has_selector = 0 - } - /^[[:space:]]*-[[:space:]]/ { flush() } - /uses:[[:space:]]*actions\/download-artifact@/ { in_download = 1 } - in_download && /^[[:space:]]*path:[[:space:]]*dist[[:space:]]*$/ { has_dist = 1 } - in_download && /^[[:space:]]*(name|pattern):[[:space:]]*/ { has_selector = 1 } - END { flush() } -' | grep -q '^broad$' \ - && fail "$wf (publish-pypi job): broad artifact download into dist/ would include SBOM/provenance assets" - echo "OK: release-publish SBOM invariants hold." From cdba6a43767d8e336901f3e4e702c7e6e4aa9292 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Thu, 28 May 2026 12:06:28 -0500 Subject: [PATCH 3/3] test: pin PyPI artifact download whitelist Signed-off-by: Nelson Spence --- tests/release_publish_invariants.py | 172 ++++++++++++++++++++++++++++ tests/release_publish_invariants.sh | 7 +- 2 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 tests/release_publish_invariants.py 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 e3547d45..7a99865a 100755 --- a/tests/release_publish_invariants.sh +++ b/tests/release_publish_invariants.sh @@ -3,11 +3,10 @@ # Release-publish SBOM invariants — pinned in CI. # # release.yml is the unified tag-triggered release pipeline. A generated -# *.cdx.json SBOM once dirtied the crate publish tree: +# *.cdx.json SBOM once dirtied the release publish flow: # * crate — the untracked SBOM dirtied the git tree, so `cargo publish` # refused it; -# PyPI artifact selection is owned by the workflow itself; this guard stays -# deliberately narrow so CI does not become a fragile YAML parser. +# * PyPI — non-distribution artifacts in dist/ make the PyPI upload fail. set -euo pipefail fail() { echo "::error::release-publish invariant violated: $*"; exit 1; } @@ -18,4 +17,6 @@ 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 +python3 tests/release_publish_invariants.py + echo "OK: release-publish SBOM invariants hold."