From d59c13d921e7655996d978f30d409c20b9da5d29 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Mon, 25 May 2026 10:56:25 -0500 Subject: [PATCH 1/9] ci: keep generated SBOMs out of the crate/PyPI publish path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex stop-gate: release-python's publish job download-artifact (merge-multiple) pulls sbom-python (.cdx.json) into dist/, which PyPI/twine reject — add 'find dist -name *.cdx.json -delete' after attestation, before the pypa publish (the SBOM stays the sbom-python build artifact). Verified the analogous crate break locally: the generated, untracked ordvec.cdx.json makes 'cargo publish' refuse a dirty tree (error: 'not yet committed'). gitignore '*.cdx.json' so cargo sees a clean tree and never bundles the SBOM into the .crate (re-verified: dry-run now exits 0, no dirty error). SBOMs remain build artifacts + covered by the attestations. --- .github/workflows/release-python.yml | 6 ++++++ .gitignore | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml index b9ee77cc..b8aaaa25 100644 --- a/.github/workflows/release-python.yml +++ b/.github/workflows/release-python.yml @@ -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 diff --git a/.gitignore b/.gitignore index 5a9ec4d1..83870d51 100644 --- a/.gitignore +++ b/.gitignore @@ -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. From fad80381580e7c69581440aeb00310c57c43f7aa Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Mon, 25 May 2026 11:05:27 -0500 Subject: [PATCH 2/9] test: pin the release-publish SBOM invariants in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release-*.yml workflows are workflow_dispatch-only, so the SBOM-then-publish interaction (which broke both publish paths) never ran in push/PR CI — it would only have surfaced at manual release ('silent failure past the CI gates'). Add tests/release_publish_invariants.sh, run by a new ci.yml 'release-guard' job, asserting: (1) *.cdx.json SBOMs are gitignored (so cargo publish stays clean and never bundles them); (2) the PyPI publish job deletes *.cdx.json from dist/ BEFORE the pypa upload. A regression now fails on every push/PR. Verified: passes with the fixes in. --- .github/workflows/ci.yml | 18 ++++++++++++++ tests/release_publish_invariants.sh | 37 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100755 tests/release_publish_invariants.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97410f20..7e290ce8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,6 +147,24 @@ 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 + steps: + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@v6 + - 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 diff --git a/tests/release_publish_invariants.sh b/tests/release_publish_invariants.sh new file mode 100755 index 00000000..ba441388 --- /dev/null +++ b/tests/release_publish_invariants.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# +# 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) The PyPI publish job must delete *.cdx.json from dist/ BEFORE the pypa +# upload step — the merge-multiple artifact download pulls the SBOM into +# dist/, and twine rejects a stray .cdx.json in the upload dir. +wf=".github/workflows/release-python.yml" +clean_line="$(grep -nE 'find .*cdx\.json.*-delete|rm .*cdx\.json' "$wf" | head -1 | cut -d: -f1 || true)" +pub_line="$(grep -n 'pypi-publish' "$wf" | head -1 | cut -d: -f1 || true)" +[ -n "$clean_line" ] || fail "$wf: publish job has no step deleting *.cdx.json from dist/" +[ -n "$pub_line" ] || fail "$wf: no pypa publish step found" +[ "$clean_line" -lt "$pub_line" ] \ + || fail "$wf: the *.cdx.json cleanup (line $clean_line) must run BEFORE the pypa publish (line $pub_line)" + +echo "OK: release-publish SBOM invariants hold." From 679e91a4fda88a7f3d9e9dde44806137d316a576 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Mon, 25 May 2026 11:59:17 -0500 Subject: [PATCH 3/9] test(ci): harden release-publish SBOM guard (anchor steps, enforce download->clean->publish order) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part (2) of release_publish_invariants.sh used broad greps that could give a false PASS. Tightened to address the PR review: - key on the pinned pypa/gh-action-pypi-publish action, not the bare string pypi-publish, which could match a job name (gemini); - anchor on uses:/run: step keys and skip YAML comment lines, so a stale comment cannot satisfy the check after the real step is removed (copilot/gemini); - also assert the cleanup runs AFTER actions/download-artifact, not only before the upload — otherwise moving it ahead of the download makes it a no-op for the downloaded SBOM (codex). Verified: passes on the current workflow (download 190 < clean 209 < publish 211) and fails on each regression (cleanup removed / reordered before download / replaced by a comment). --- tests/release_publish_invariants.sh | 31 ++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/tests/release_publish_invariants.sh b/tests/release_publish_invariants.sh index ba441388..c230982e 100755 --- a/tests/release_publish_invariants.sh +++ b/tests/release_publish_invariants.sh @@ -23,14 +23,31 @@ 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) The PyPI publish job must delete *.cdx.json from dist/ BEFORE the pypa -# upload step — the merge-multiple artifact download pulls the SBOM into -# dist/, and twine rejects a stray .cdx.json in the upload dir. +# (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. Match real step keys only: anchor on `uses:`/`run:`, skip YAML +# comment lines, and key on the pinned action name (`pypa/gh-action-pypi-publish`) +# rather than the bare string `pypi-publish`, which could match a job name. wf=".github/workflows/release-python.yml" -clean_line="$(grep -nE 'find .*cdx\.json.*-delete|rm .*cdx\.json' "$wf" | head -1 | cut -d: -f1 || true)" -pub_line="$(grep -n 'pypi-publish' "$wf" | head -1 | cut -d: -f1 || true)" -[ -n "$clean_line" ] || fail "$wf: publish job has no step deleting *.cdx.json from dist/" -[ -n "$pub_line" ] || fail "$wf: no pypa publish step found" +[ -f "$wf" ] || fail "$wf: workflow file not found" + +# Line number of the first real (non-comment) line matching the regex, if any. +step_line() { grep -nE "$1" "$wf" | grep -vE '^[0-9]+:[[:space:]]*#' | head -1 | cut -d: -f1; } + +dl_line="$(step_line 'uses:[[:space:]]*actions/download-artifact' || true)" +clean_line="$(step_line 'run:.*(find|rm).*cdx\.json' || true)" +pub_line="$(step_line 'uses:[[:space:]]*pypa/gh-action-pypi-publish' || true)" + +[ -n "$dl_line" ] || fail "$wf: no actions/download-artifact step found" +[ -n "$clean_line" ] || fail "$wf: publish job has no run: step deleting *.cdx.json from dist/" +[ -n "$pub_line" ] || fail "$wf: no pypa/gh-action-pypi-publish step found" + +[ "$dl_line" -lt "$clean_line" ] \ + || fail "$wf: the *.cdx.json cleanup (line $clean_line) must run AFTER actions/download-artifact (line $dl_line), else it is a no-op for the downloaded SBOM" [ "$clean_line" -lt "$pub_line" ] \ || fail "$wf: the *.cdx.json cleanup (line $clean_line) must run BEFORE the pypa publish (line $pub_line)" From 80c1bb9e1a3b5056a2b71b31bbb36bb35f189d26 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Mon, 25 May 2026 12:07:32 -0500 Subject: [PATCH 4/9] fix(ci): require an actual deletion in the SBOM-cleanup guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hardened part (2) pattern run:.*(find|rm).*cdx.json dropped the original's -delete requirement, so a no-op 'find ... *.cdx.json' (no -delete, lists only) would satisfy the cleanup check while leaving the SBOM in dist/ — reintroducing the PyPI upload failure. Restore the deletion proof: require 'find ... *.cdx.json ... -delete' or 'rm ... *.cdx.json' (the original semantics), keeping the comment-skip and uses:/run: step-key anchoring. Verified: passes on the current workflow and now FAILS on a non-deleting find (Codex stop-review case), alongside removed / reordered-before-download / comment-only regressions. --- tests/release_publish_invariants.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/release_publish_invariants.sh b/tests/release_publish_invariants.sh index c230982e..01435101 100755 --- a/tests/release_publish_invariants.sh +++ b/tests/release_publish_invariants.sh @@ -39,7 +39,10 @@ wf=".github/workflows/release-python.yml" step_line() { grep -nE "$1" "$wf" | grep -vE '^[0-9]+:[[:space:]]*#' | head -1 | cut -d: -f1; } dl_line="$(step_line 'uses:[[:space:]]*actions/download-artifact' || true)" -clean_line="$(step_line 'run:.*(find|rm).*cdx\.json' || true)" +# The cleanup must actually DELETE: `find … *.cdx.json … -delete` or `rm … *.cdx.json`. +# A bare `find` that only lists/prints the SBOM would leave it in dist/ (a no-op), +# so requiring the deleting action is what proves the regression is fixed. +clean_line="$(step_line 'run:.*(find.*cdx\.json.*-delete|rm[[:space:]].*cdx\.json)' || true)" pub_line="$(step_line 'uses:[[:space:]]*pypa/gh-action-pypi-publish' || true)" [ -n "$dl_line" ] || fail "$wf: no actions/download-artifact step found" From 1e44a80c83381e0ae2f977b54e12d9aaa778ebda Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Mon, 25 May 2026 12:08:39 -0500 Subject: [PATCH 5/9] ci: pin release-guard checkout to a commit SHA (qodo review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin actions/checkout in the new release-guard job to de0fac2e4500dabe0009e67214ff5f5447ce83dd (v6.0.2) — the immutable SHA already used by the release-crate/release-python/codeql/audit workflows — instead of the floating @v6 tag, matching the repo's pinned-action posture for this new job. (The other 8 actions/checkout@v6 in ci.yml and 2 in python.yml are pre-existing and out of scope for this SBOM PR; a full ci.yml/python.yml pin can follow.) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c806dc25..767a753e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,7 +161,7 @@ jobs: - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: release-publish SBOM invariants run: bash tests/release_publish_invariants.sh From 49b97db3a224ca772ec1f8df2f45a6d3f881dc13 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Mon, 25 May 2026 12:22:29 -0500 Subject: [PATCH 6/9] fix(ci): scope SBOM guard to the publish job + accept multi-line run: (qodo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two robustness fixes to part (2) of release_publish_invariants.sh: - Publish-scoped (qodo #1): step_line grepped the whole workflow with head -1, so a download-artifact in another job could satisfy the ordering even if the publish job regressed. Now extract the 'publish:' job body (its key to the next 2-space-indented job key or EOF) and search only within it. - Multi-line aware (qodo #4): the cleanup was anchored on a single-line 'run:', so a 'run: |' block would false-fail. Now match the delete command on its own line, so both 'run: ... -delete' and a multi-line 'run: |' block work — still requiring a real delete ('find ... -delete' or 'rm ... *.cdx.json'). Verified A-G: passes on the real workflow and multi-line run:| (F); fails on removed / reordered-in-publish / comment-only / non-deleting-find; and is no longer fooled by a decoy download-artifact in another job (G). --- tests/release_publish_invariants.sh | 43 ++++++++++++++++++----------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/tests/release_publish_invariants.sh b/tests/release_publish_invariants.sh index 01435101..29ee13d0 100755 --- a/tests/release_publish_invariants.sh +++ b/tests/release_publish_invariants.sh @@ -29,29 +29,40 @@ done # -> 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. Match real step keys only: anchor on `uses:`/`run:`, skip YAML -# comment lines, and key on the pinned action name (`pypa/gh-action-pypi-publish`) -# rather than the bare string `pypi-publish`, which could match a job name. +# 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 on its own +# line, so it works for both `run: ... -delete` and a multi-line `run: |` block; +# comment lines are skipped; and the publish step keys on the pinned action +# name (not the bare string `pypi-publish`, which could match a job name). wf=".github/workflows/release-python.yml" [ -f "$wf" ] || fail "$wf: workflow file not found" -# Line number of the first real (non-comment) line matching the regex, if any. -step_line() { grep -nE "$1" "$wf" | grep -vE '^[0-9]+:[[:space:]]*#' | head -1 | cut -d: -f1; } +# 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")" -dl_line="$(step_line 'uses:[[:space:]]*actions/download-artifact' || true)" -# The cleanup must actually DELETE: `find … *.cdx.json … -delete` or `rm … *.cdx.json`. -# A bare `find` that only lists/prints the SBOM would leave it in dist/ (a no-op), -# so requiring the deleting action is what proves the regression is fixed. -clean_line="$(step_line 'run:.*(find.*cdx\.json.*-delete|rm[[:space:]].*cdx\.json)' || true)" -pub_line="$(step_line 'uses:[[:space:]]*pypa/gh-action-pypi-publish' || true)" +# 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; } -[ -n "$dl_line" ] || fail "$wf: no actions/download-artifact step found" -[ -n "$clean_line" ] || fail "$wf: publish job has no run: step deleting *.cdx.json from dist/" -[ -n "$pub_line" ] || fail "$wf: no pypa/gh-action-pypi-publish step found" +dl_line="$(in_job 'uses:[[:space:]]*actions/download-artifact' || true)" +# Match the deletion command itself (not the `run:` key), so a multi-line +# `run: |` block works too. Still requires a real delete — `find ... -delete` or +# `rm ... *.cdx.json` — not a bare reference that would leave the SBOM in dist/. +clean_line="$(in_job '(find.*cdx\.json.*-delete|rm[[:space:]].*cdx\.json)' || 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: the *.cdx.json cleanup (line $clean_line) must run AFTER actions/download-artifact (line $dl_line), else it is a no-op for the downloaded SBOM" + || 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: the *.cdx.json cleanup (line $clean_line) must run BEFORE the pypa publish (line $pub_line)" + || fail "$wf (publish job): the *.cdx.json cleanup must run BEFORE the pypa publish" echo "OK: release-publish SBOM invariants hold." From 6ad1b3f66e4044f80364cc7cb36d8cfc4d996ad3 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Mon, 25 May 2026 12:22:29 -0500 Subject: [PATCH 7/9] ci: least-privilege contents:read on the release-guard job (qodo) Add an explicit job-level 'permissions: contents: read' to release-guard. The workflow already defaults to contents: read, but an explicit per-job block is the repo convention for scoped jobs (release-python.yml's publish job does the same) and constrains blast radius if the top-level default is ever widened. The guard only runs git check-ignore + grep, so read-only is sufficient. --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 767a753e..e566fca7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,6 +157,11 @@ jobs: 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: From 48159526ac6bf7e23bf49db7d7651462324f8911 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Mon, 25 May 2026 12:31:55 -0500 Subject: [PATCH 8/9] fix(ci): match SBOM delete only in executing run: context (Codex review) Round-2 matched the delete command on ANY line in the publish job (to support multi-line run:), which also accepted NON-executing text: a step name:, an env:/with: value, or prose mentioning 'find ... -delete' would satisfy the guard while nothing actually deletes the SBOM. Replace the bare match with a small awk state machine that counts a delete only as a single-line 'run: ... -delete' or a line inside that step's 'run: |'/'run: >' block. Still publish-scoped, deletion-proof, and ordering-checked. Verified A-I: single- line and multi-line run: pass; removed / reordered / comment-only / non-deleting find / name-text / non-run-field-text all fail. --- tests/release_publish_invariants.sh | 33 ++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/tests/release_publish_invariants.sh b/tests/release_publish_invariants.sh index 29ee13d0..098dbc7a 100755 --- a/tests/release_publish_invariants.sh +++ b/tests/release_publish_invariants.sh @@ -30,10 +30,10 @@ done # 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 on its own -# line, so it works for both `run: ... -delete` and a multi-line `run: |` block; -# comment lines are skipped; and the publish step keys on the pinned action -# name (not the bare string `pypi-publish`, which could match a job name). +# 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" @@ -50,10 +50,27 @@ job="$(sed -n "${pub_start},${pub_end}p" "$wf")" 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)" -# Match the deletion command itself (not the `run:` key), so a multi-line -# `run: |` block works too. Still requires a real delete — `find ... -delete` or -# `rm ... *.cdx.json` — not a bare reference that would leave the SBOM in dist/. -clean_line="$(in_job '(find.*cdx\.json.*-delete|rm[[:space:]].*cdx\.json)' || 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 && !is_comment) { # block content + if ($0 ~ del) { print NR; exit } + next + } + 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" From 58a2facd602df69de5a5e85e6be67cd180e99575 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Mon, 25 May 2026 12:36:31 -0500 Subject: [PATCH 9/9] fix(ci): keep run: block detection intact across shell # comment lines (Codex) The run-context awk treated any line indented inside a run: |/> block as a comment (via !is_comment) and exited the block early, so a shell comment before the real delete (e.g. '# remove the SBOM' then 'find ... -delete') made the guard miss a valid cleanup and false-fail. A # line inside a block scalar is literal shell, not a YAML comment: block membership is now by indentation alone, and the !is_comment gate applies only to whether a line counts as the delete command. Verified A-K: a comment before the delete inside a block now passes (J); delete text appearing only in a block comment still fails (K); all prior cases hold. --- tests/release_publish_invariants.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/release_publish_invariants.sh b/tests/release_publish_invariants.sh index 098dbc7a..8f8a56b2 100755 --- a/tests/release_publish_invariants.sh +++ b/tests/release_publish_invariants.sh @@ -62,9 +62,9 @@ clean_line="$(printf '%s\n' "$job" | awk ' { is_comment = ($0 ~ /^[[:space:]]*#/) } in_block { if ($0 ~ /^[[:space:]]*$/) next # blank line stays in block - if (indent($0) > block_indent && !is_comment) { # block content - if ($0 ~ del) { print NR; exit } - next + 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 }