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
12 changes: 7 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
172 changes: 172 additions & 0 deletions tests/release_publish_invariants.py
Original file line number Diff line number Diff line change
@@ -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()
90 changes: 9 additions & 81 deletions tests/release_publish_invariants.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Loading