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
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,29 @@ jobs:
# (ordvec-python is publish = false and ships to PyPI via maturin).
run: cargo publish -p ordvec --dry-run --locked

# ----------------------------------------------------------------------
# Pin the release-publish SBOM invariants. release-*.yml are
# workflow_dispatch-only, so their "generate SBOM then publish" flow never runs
# in push/PR CI — a generated *.cdx.json once broke both publish paths and would
# only have surfaced at manual release. This exercises the invariants every
# push/PR (see tests/release_publish_invariants.sh).
# ----------------------------------------------------------------------
release-guard:
name: release-publish invariants
runs-on: ubuntu-latest
# Least-privilege: the guard only runs git check-ignore + grep on the
# checked-out tree, so a read-only token is all it needs (explicit here even
# though the workflow default is already contents: read).
permissions:
contents: read
steps:
- uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: release-publish SBOM invariants
run: bash tests/release_publish_invariants.sh

# ----------------------------------------------------------------------
# Supply-chain policy gate. The `deps` job's cargo-tree grep is a coarse
# "no BLAS/ndarray/faer" guard; this job enforces the fine-grained, auditable
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/release-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -201,5 +201,11 @@ jobs:
subject-path: |
dist/*.whl
dist/*.tar.gz
# The merge-multiple download above pulls EVERY artifact into dist/,
# including sbom-python (.cdx.json). PyPI/twine accept only wheels + sdists,
# so a stray .cdx.json fails the upload. Drop it here (after attestation,
# which covers only the dists); the SBOM remains the `sbom-python` artifact.
- name: Drop the SBOM from the PyPI upload dir (wheels + sdist only)
run: find dist -name '*.cdx.json' -delete
- name: Publish to PyPI (Trusted Publishing; PEP 740 attestations on by default)
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
*.tvsb
*.npy

# CycloneDX SBOMs generated by cargo-cyclonedx in the release workflows (uploaded
# as build artifacts, never committed) — keeps `cargo publish` from treating them
# as an uncommitted/dirty working tree and from bundling them into the .crate.
*.cdx.json

# Local Claude Code project context & agent worktrees — kept out of the
# public repo (internal working notes). The public contributor guide lives
# in CONTRIBUTING.md.
Expand Down
85 changes: 85 additions & 0 deletions tests/release_publish_invariants.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
Comment thread
project-navi-bot marked this conversation as resolved.
#
# Release-publish SBOM invariants — pinned in CI.
#
# release-crate.yml / release-python.yml are workflow_dispatch-only, so their
# "generate a CycloneDX SBOM, then publish" flow never runs in push/PR CI. A
# generated *.cdx.json SBOM once broke BOTH publish paths and would only have
# surfaced at a manual 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 manual release time.
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.)
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/
# -> 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` 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-python.yml"
[ -f "$wf" ] || fail "$wf: workflow file not found"

# Extract the `publish` job body: from its ` publish:` 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 (publish) job.
pub_start="$(grep -nE '^ publish:[[:space:]]*$' "$wf" | head -1 | cut -d: -f1)"
[ -n "$pub_start" ] || fail "$wf: no 'publish:' 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 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. Still requires a real delete
# (`find ... -delete` or `rm ... *.cdx.json`), not a bare mention.
clean_line="$(printf '%s\n' "$job" | awk '
function indent(s, i){ i = match(s, /[^ ]/); return (i ? i - 1 : length(s)) }
BEGIN { del = "find.*cdx\\.json.*-delete|rm[[:space:]].*cdx\\.json" }
{ 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 job): no actions/download-artifact step found"
[ -n "$clean_line" ] || fail "$wf (publish job): no step deleting *.cdx.json from dist/ (need 'find ... -delete' or 'rm ... *.cdx.json')"
[ -n "$pub_line" ] || fail "$wf (publish job): no pypa/gh-action-pypi-publish step found"

[ "$dl_line" -lt "$clean_line" ] \
|| fail "$wf (publish 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 job): the *.cdx.json cleanup must run BEFORE the pypa publish"

echo "OK: release-publish SBOM invariants hold."
Loading