From d993916b7a729a91a232f4566e8d813cbba2c346 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 7 Sep 2026 00:07:39 +0300 Subject: [PATCH 1/6] ci(release): run release.yml on release-surface PRs; rehearse the PyPI publish; add opt-in TestPyPI leg (#342, #350) - pull_request trigger on 5 paths: release.yml, .github/actions/**, crates/mds-napi/**, crates/mds-python/**, scripts/verify-napi-names.mjs - rehearse-publish-python job: dry-run OIDC exchange, unguarded (PF-039), blocks publish-crates - publish-testpypi job: opt-in via workflow_dispatch testpypi:true, in TIER_B_EXPECTED_SKIPPED - version-gate: GHCR manifest probe for pypa/gh-action-pypi-publish pin (PF-040); step-level if: guard on CI-history step so version-gate runs (not skips) on PRs (ADR-013 amendment) - verify-pr-checks.mjs: RELEASE_SURFACE/RELEASE_SURFACE_CONTEXTS/matchesReleaseSurface exports; D-PR6 block requires Rehearse PyPI publish (no upload) on release-surface PRs; bounded PR-files pagination with graceful degradation - 13 new spec tests (S5-S13 in release-auth-probe.spec.mjs, D-PR5f + D-PR6a-g in verify-pr-checks.spec.mjs); Snyk: security/snyk (dean0x) is the scan of record (Snyk MCP unavailable this session) --- .github/workflows/release.yml | 154 +++++++++++-- CHANGELOG.md | 1 + RELEASING.md | 60 ++++- scripts/__test__/release-auth-probe.spec.mjs | 228 +++++++++++++++++++ scripts/__test__/verify-pr-checks.spec.mjs | 202 +++++++++++++++- scripts/verify-pr-checks.mjs | 173 +++++++++++++- 6 files changed, 775 insertions(+), 43 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e118743a..821c2377 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,27 @@ name: Release -# Two entry points: +# Three entry points: # * push a version tag (v*) -> full coordinated release -# * workflow_dispatch (no inputs) -> dry run (build + verify, no publish) +# * pull_request touching release-surface paths -> rehearse only, no publish +# * workflow_dispatch -> dry run (build + verify, no publish) +# testpypi: true -> same + publish to TestPyPI (opt-in leg) on: push: tags: - "v*" - workflow_dispatch: {} + pull_request: + paths: + - '.github/workflows/release.yml' + - '.github/actions/**' + - 'crates/mds-napi/**' + - 'crates/mds-python/**' + - 'scripts/verify-napi-names.mjs' + workflow_dispatch: + inputs: + testpypi: + description: 'Publish to TestPyPI (rehearsal; skip-existing is on)' + type: boolean + default: false permissions: contents: read @@ -50,7 +64,7 @@ jobs: # to an empty string in GitHub Actions — not an error — so this must be checked # explicitly before cargo publish runs and makes crates.io publish irreversible. if [ -z "$NODE_AUTH_TOKEN" ]; then - echo "::error::NPM_TOKEN is empty or unset — npm publish would fail AFTER cargo publish (irreversible). Set the NPM_TOKEN repo secret. (security-08)" + echo "::error::NPM_TOKEN is empty or unset — npm publish would fail AFTER cargo publish (irreversible). Set the NPM_TOKEN repo secret. NOTE: Dependabot and fork pull_request events do not receive repository secrets; only first-party branch PRs do. (security-08)" exit 1 fi # Guard: cargo token must be non-empty. A missing token fails before the @@ -87,6 +101,29 @@ jobs: echo "::error::PyPI OIDC mint failed (HTTP $CODE) — trusted publisher missing, expired, or mismatched. Fix BEFORE tagging; crates.io is irreversible." jq -r '.message // .' /tmp/mint.json; exit 1; } echo "PyPI trusted publisher OK" + # PF-040: verify the pypa/gh-action-pypi-publish tag pin resolves in GHCR + # before any irreversible publish. The action derives its GHCR image tag + # from github.action_ref — a commit-SHA or annotated-tag-object-SHA pin + # causes "manifest unknown" because GHCR only publishes images for release + # tag names. This probe catches a bad pin (SHA instead of tag name) at + # version-gate time, before the build matrix starts. Anonymous REST; no + # Docker CLI needed. Runs on all triggers (PF-039). + - name: "Probe GHCR: verify pypa/gh-action-pypi-publish pin resolves (PF-040, #350)" + shell: bash + run: | + set -euo pipefail + PIN_REF=v1.14.2 + TOKEN=$(curl -sf \ + 'https://ghcr.io/token?scope=repository:pypa/gh-action-pypi-publish:pull' \ + | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>process.stdout.write(JSON.parse(d).token))") + STATUS=$(curl -sf -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer ${TOKEN}" \ + "https://ghcr.io/v2/pypa/gh-action-pypi-publish/manifests/${PIN_REF}") + if [ "$STATUS" != "200" ]; then + echo "::error::GHCR manifest probe failed — pypa/gh-action-pypi-publish:${PIN_REF} returned HTTP ${STATUS}. The pin in publish-python/publish-testpypi must be a release tag name, not a SHA (PF-040). If the tag was bumped, update PIN_REF here and the uses: pins in publish-python and publish-testpypi." + exit 1 + fi + echo "GHCR probe OK: pypa/gh-action-pypi-publish:${PIN_REF} → HTTP ${STATUS}" - name: "Assert synchronized versions, no file: refs" run: node scripts/verify-versions.mjs # #288: Source-hygiene gate — also runs on tag pushes via this job. @@ -100,11 +137,15 @@ jobs: - name: "Run positive-control and class-completeness suite" run: npm run test:gates # reliability-12: assert the SHA being released went green in CI before - # any irreversible publish starts. Runs on BOTH triggers - a tag push - # verifies the tagged commit, a workflow_dispatch dry run verifies the - # dispatched ref's HEAD - so the dry run now exercises this gate instead - # of skipping it. Cancelled, failed, in-progress and absent runs all fail - # closed (PF-017; PF-013: absence is not success). + # any irreversible publish starts. Runs on tag push and workflow_dispatch + # (dry run) - a tag push verifies the tagged commit, a dispatch verifies + # the dispatched ref's HEAD. Skipped on pull_request events: a PR's merge + # commit may not have a ci.yml run yet, and PRs exercise the rehearsal + # jobs rather than the full publish sequence. Step-level if: (not + # job-level) keeps version-gate itself running (and succeeding) on PRs so + # Tier-B verifier sees a success, not a skipped result (ADR-013 amendment + # 2026-09-06). Cancelled, failed, in-progress and absent runs all fail + # closed on tag/dispatch (PF-017; PF-013: absence is not success). # Cannot reuse scripts/verify-pr-checks.mjs here - that script requires a # PR number, and tag pushes reference a commit, not a PR. # Branch protection is NOT read: /branches/main/protection needs @@ -112,6 +153,7 @@ jobs: # hold (it returned HTTP 403 on the v0.4.0 tag push). ADR-013's # required-context count is mirrored into MIN_CI_JOBS below instead. - name: "Assert tagged SHA has green CI history (PF-017)" + if: github.event_name != 'pull_request' env: GH_TOKEN: ${{ github.token }} run: | @@ -192,6 +234,10 @@ jobs: echo "CI run ${RUN_ID} (event=${RUN_EVENT}): ${RUN_URL}" echo " ${N} job(s), all completed+success (floor: ${MIN_CI_JOBS})" echo "CI history OK for ${SHA}" + - name: "CI-history check skipped on pull_request (step-level guard — see ADR-013 amendment)" + if: github.event_name == 'pull_request' + run: | + echo "::notice::CI-history gate skipped for pull_request events — only required on tag push and workflow_dispatch dry runs." # --------------------------------------------------------------------------- # A6 — cross-compile the native addon for all 7 targets. @@ -648,9 +694,73 @@ jobs: if-no-files-found: error # =========================================================================== - # Everything below publishes — gated to tag pushes only. - # workflow_dispatch (no inputs) stops above; publish jobs see a non-tag ref - # and their startsWith(github.ref, 'refs/tags/v') condition evaluates false. + # Rehearsal jobs — run on pull_request, workflow_dispatch, AND tag push. + # Intentionally unguarded (no if:) so PRs exercise the OIDC exchange before + # any irreversible crates.io publish (PF-039: tag-guarded steps unexercised). + # =========================================================================== + + # --------------------------------------------------------------------------- + # Rehearse the PyPI OIDC exchange + wheel shape without uploading. + # Runs on all three triggers. publish-crates blocks on this so a failed + # OIDC exchange aborts before the irreversible crates.io write. + # --------------------------------------------------------------------------- + rehearse-publish-python: + name: Rehearse PyPI publish (no upload) + needs: [version-gate, stage-and-verify-napi, build-python] + runs-on: ubuntu-latest + permissions: + id-token: write # OIDC for PyPI trusted publisher dry-run + contents: read + steps: + - name: Download Python artifacts + uses: actions/download-artifact@v8 + with: + pattern: python-* + path: python-dist/ + merge-multiple: true + - name: Rehearse PyPI publish (dry-run — no upload) + # IMPORTANT: Do NOT change this pin back to a commit SHA — see the + # extended comment in the publish-python step below (PF-040). + uses: pypa/gh-action-pypi-publish@v1.14.2 + with: + packages-dir: python-dist/ + dry-run: true + + # --------------------------------------------------------------------------- + # Opt-in TestPyPI leg — only runs on workflow_dispatch with testpypi: true. + # Skipped on all PR and standard dispatch runs; its name must appear in + # TIER_B_EXPECTED_SKIPPED in scripts/verify-pr-checks.mjs (ADR-013). + # publish-testpypi is intentionally NOT in publish-crates needs: — crates.io + # ordering is handled by rehearse-publish-python, not this optional leg. + # --------------------------------------------------------------------------- + publish-testpypi: + name: Publish to TestPyPI (rehearsal) + needs: [version-gate, stage-and-verify-napi, build-python] + if: ${{ inputs.testpypi == true }} + runs-on: ubuntu-latest + permissions: + id-token: write # OIDC for TestPyPI trusted publisher + contents: read + steps: + - name: Download Python artifacts + uses: actions/download-artifact@v8 + with: + pattern: python-* + path: python-dist/ + merge-multiple: true + - name: Publish to TestPyPI + # IMPORTANT: Do NOT change this pin back to a commit SHA — see the + # extended comment in the publish-python step below (PF-040). + uses: pypa/gh-action-pypi-publish@v1.14.2 + with: + packages-dir: python-dist/ + repository-url: https://test.pypi.org/legacy/ + skip-existing: true + attestations: true + + # =========================================================================== + # Everything below publishes to live registries — gated to tag pushes only. + # Non-tag refs (PRs, dispatch without inputs) stop at the rehearsal jobs. # =========================================================================== # --------------------------------------------------------------------------- @@ -668,8 +778,11 @@ jobs: # on Python here avoids a partial state where crates.io is live but PyPI # fails (PF-023; PF-039: build-python runs unguarded so this blocking is # exercised by the dispatch dry run). - needs: [version-gate, stage-and-verify-napi, build-python] - if: ${{ !cancelled() && needs.version-gate.result == 'success' && needs.stage-and-verify-napi.result == 'success' && needs.build-python.result == 'success' && startsWith(github.ref, 'refs/tags/v') }} + # rehearse-publish-python is listed here so a failed OIDC exchange aborts + # before the irreversible crates.io write (if PyPI will fail, we learn before + # crates.io is already live at the new version). + needs: [version-gate, stage-and-verify-napi, build-python, rehearse-publish-python] + if: ${{ !cancelled() && needs.version-gate.result == 'success' && needs.stage-and-verify-napi.result == 'success' && needs.build-python.result == 'success' && needs.rehearse-publish-python.result == 'success' && startsWith(github.ref, 'refs/tags/v') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -903,7 +1016,7 @@ jobs: [ "${#sd[@]}" -eq 1 ] || { echo "::error::expected 1 sdist, found ${#sd[@]}"; exit 1; } echo "Python distributions complete: ${#whl[@]} wheels + ${#sd[@]} sdist" - name: Publish to PyPI - # IMPORTANT: Do NOT change this pin back to a commit SHA. + # IMPORTANT: Do NOT change this pin back to a commit SHA (PF-040). # pypa/gh-action-pypi-publish is a Docker-based composite action that # derives its GHCR image tag from `github.action_ref`. When you pin via # `@`, GitHub Actions sets action_ref to that SHA, and the action @@ -911,12 +1024,11 @@ jobs: # images for tagged releases — not for every commit SHA. # The v0.4.1 release used `@a892a5a...`, which is the annotated *tag # object* SHA for v1.14.2 (not the commit SHA). No GHCR image exists for - # it, so the publish-python job failed with "manifest unknown". The - # underlying commit SHA (dc37677b...) does have a GHCR image and could - # be used as a SHA pin, but the risk of the same annotated-vs-commit - # confusion recurring outweighs the marginal supply-chain benefit here. - # Using the version tag is unambiguous: v1.14.2 maps directly to a - # published GHCR image (verified via GitHub Packages API, 2026-09-03). + # it, so the publish-python job failed with "manifest unknown". + # Using the release tag name is unambiguous. The version-gate GHCR probe + # verifies the pin resolves before any irreversible publish starts. + # When bumping this pin: update PIN_REF in the GHCR probe step above + # AND the uses: pin in rehearse-publish-python and publish-testpypi. uses: pypa/gh-action-pypi-publish@v1.14.2 with: packages-dir: python-dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3186996d..a79b9a7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cargo dependency sweep: napi 3.9.0 → 3.12.2, napi-derive 3.5.6 → 3.6.3, napi-build 2.3.2 → 2.4.1 (napi-sys 3.3.0, napi-derive-backend 6.1.2), pyo3 0.29.0 → 0.29.2, clap 4.6.1 → 4.6.6, similar 3.1.1 → 3.2.0, wasm-bindgen 0.2.121 → 0.2.126 (js-sys 0.3.103, wasm-bindgen-futures 0.4.76, wasm-bindgen-test 0.3.76), serde 1.0.228 → 1.0.229, serde_json 1.0.150 → 1.0.151, thiserror 2.0.18 → 2.0.20, libc 0.2.186 → 0.2.189. Supersedes Dependabot #354 #360 #359 #358 #280 #251 #249 #246 #243. - npm dependency sweep: relaxed the three phantom floor pins to caret ranges — fast-uri 3.1.5 → ^3.1.6 (oldest release patching GHSA-5jgf-p345-68v8, GHSA-fph4-wmhf-6fwf, GHSA-f65p-4m7j-42xc, GHSA-jqff-g426-hqxp), nanoid 3.3.18 → ^3.3.18, js-yaml 4.3.1 → ^4.3.1 (#336); @napi-rs/cli ^3.0.0 → ^3.8.6 (lock 3.7.0 → 3.8.6); vite lock 8.1.5 → 8.2.2; Dependabot `ignore` rules for semver-major bumps of the three phantom pins. Supersedes Dependabot #315 #332 #346 #362 #355 #357 #279. - GitHub Actions sweep: actions/checkout v6 → v7 (16 call sites: 9 ci.yml + 7 release.yml), actions/setup-node v6 → v7 (6 sites), actions/setup-python v5 → v7 (5 sites, ci.yml only; action runtime node20 → node24), PyO3/maturin-action pin normalized from the v1.51.0 annotated-tag object (`3e2bdf6`) to the commit it points to (`e83996d1`), same version (PF-040); Dependabot `ignore` for typescript semver-major version updates pending the TS 7 migration (#364). Supersedes Dependabot #111, #189, #241, #356; replaces #169. +- Release-surface PR gate: `release.yml` now triggers on `pull_request` events touching `.github/workflows/release.yml`, `.github/actions/**`, `crates/mds-napi/**`, `crates/mds-python/**`, and `scripts/verify-napi-names.mjs`; adds `rehearse-publish-python` job (OIDC dry-run, unguarded per PF-039) and `publish-testpypi` opt-in leg; GHCR manifest probe in `version-gate` for the `pypa/gh-action-pypi-publish` pin (PF-040, #350); CI-history step guard is now step-level (ADR-013 amendment); `verify-pr-checks.mjs` requires `Rehearse PyPI publish (no upload)` on release-surface PRs (#342, #350). ## [0.4.2] — 2026-09-03 diff --git a/RELEASING.md b/RELEASING.md index 67e85d81..67b9ce05 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -59,6 +59,19 @@ These are **not** automated and must be done before the first release: This step is NOT tag-guarded so it runs in the `workflow_dispatch` dry run too, exercising the PyPI trust chain before the real tag push (PF-039). +6. **Configure TestPyPI trusted publisher** (optional, needed for `testpypi: true` + dispatch runs) at [test.pypi.org/manage/account/publishing](https://test.pypi.org/manage/account/publishing/): + - Project name: `markdown-script` + - Owner / repository: `dean0x/mdscript` + - Workflow filename: `release.yml` + - Environment name: **leave blank** + + The `publish-testpypi` job is a dispatch-input-guarded opt-in leg (`testpypi: true` + on `workflow_dispatch`). It is skipped on all PR and standard dispatch runs; + `TIER_B_EXPECTED_SKIPPED` lists its name so the pre-merge verifier tolerates the + skipped conclusion. The trusted publisher for TestPyPI is independent of the PyPI + one — both must be configured separately. + ## Pre-flight (before tagging) Run the local dry-runs and gates: @@ -125,16 +138,17 @@ gh workflow run release.yml # workflow_dispatch — builds the 7-target # Python wheel matrix, stages packages, # runs the A3 name<->loader gate and the # Python readelf linkage gate, uploads - # artifacts. Publishes NOTHING. + # artifacts. Rehearses the PyPI OIDC + # exchange. Publishes NOTHING. ``` The dry-run workflow runs `version-gate` in full, which now includes the **credential probe** (security-08): it calls `npm whoami` against the live registry to verify the `NPM_TOKEN` is valid, guards `CARGO_REGISTRY_TOKEN` -for non-empty, and probes the PyPI trusted publisher via the OIDC mint-token -exchange. A revoked token, absent secret, or misconfigured trusted publisher -therefore fails the dry run — this closes the former gap where a bad credential -was only discovered after `cargo publish` had already made an irreversible +for non-empty, probes the PyPI trusted publisher via the OIDC mint-token +exchange, and probes the GHCR manifest for the `pypa/gh-action-pypi-publish` +pin (PF-040). A revoked token, absent secret, misconfigured trusted publisher, +or broken action pin therefore fails the dry run — all before any irreversible crates.io release. **Note:** `npm whoami` verifies authentication, not publish rights to the @@ -145,12 +159,33 @@ The dry run also exercises the **CI-history gate** (PF-017), asserting a completed+success `CI` run for the dispatched ref's HEAD. Dispatch it only after that ref's CI has finished, or the gate fails closed on a still-running run. +The dry run also runs the new **`rehearse-publish-python` job** — a `dry-run: true` +publish that exercises the OIDC exchange end-to-end without uploading any wheels. +A misconfigured or expired trusted publisher fails here, before `publish-crates` +starts (PF-039: the rehearsal job is intentionally unguarded so it runs on dispatch +and PRs, not just tag pushes). + +Five jobs are expected-skipped on a standard `workflow_dispatch` dry run and +are listed in `TIER_B_EXPECTED_SKIPPED` in `scripts/verify-pr-checks.mjs`: +`Publish to crates.io`, `Publish to npm`, `Publish to PyPI`, `GitHub Release`, +and `Publish to TestPyPI (rehearsal)`. + Confirm the **A3 name-gate** step (`scripts/verify-napi-names.mjs`) passes in that run. **This is a hard checkpoint** — if the generated platform package names or their `.node` filenames drift from the hand-written `crates/mds-napi/index.js` loader, the published universal package will fail to load the native binary at runtime on the affected platform. Do not proceed past a failing gate. +### PyPI publish rehearsal + +Release-surface PRs (those touching `.github/workflows/release.yml`, +`.github/actions/**`, `crates/mds-napi/**`, `crates/mds-python/**`, or +`scripts/verify-napi-names.mjs`) also trigger the workflow via the +`pull_request` event. On such PRs, `verify-pr-checks.mjs` requires three +additional check-runs: `Version gate`, `Stage + verify platform packages`, and +`Rehearse PyPI publish (no upload)`. All other publish jobs are skipped and +their skipped conclusions are tolerated by the verifier. + ## Release ### Tag-push (the only path) @@ -187,21 +222,24 @@ The release is driven by pushing a `vX.Y.Z` tag. This is how all versions have s ### What happens after tagging The `release.yml` workflow runs, in order: - 1. **version-gate** — synchronized-version check (fails fast). + 1. **version-gate** — synchronized-version check, credential probe, GHCR pin + probe, PyPI OIDC probe, and CI-history gate (fails fast). 2. **build-napi** (parallel with build-python) — cross-compiles the addon for all 7 targets. 3. **build-python** (parallel with build-napi) — builds `cp311-abi3` wheels for 7 platforms + sdist, runs the readelf linkage gate on Linux legs. 4. **stage-and-verify-napi** — `napi create-npm-dirs` + `artifacts`, copies LICENSE into each platform dir, runs the **A3 name-gate**. - 5. **publish-crates** — blocked until BOTH `stage-and-verify-napi` and - `build-python` succeed (so a Python build failure aborts before crates.io, - which is irreversible — PF-023). `cargo publish` `mds-core`, polls the + 5. **rehearse-publish-python** — `dry-run: true` publish to PyPI; exercises the + OIDC exchange end-to-end without uploading any wheels. publish-crates blocks + on this so a broken trusted publisher aborts before crates.io (irreversible). + 6. **publish-crates** — blocked until `stage-and-verify-napi`, `build-python`, + AND `rehearse-publish-python` succeed. `cargo publish` `mds-core`, polls the crates.io index for up to 5 min (bounded, max 20 × 15 s), then `mds-cli`. - 6. **publish-npm** and **publish-python** (parallel, both after publish-crates) + 7. **publish-npm** and **publish-python** (parallel, both after publish-crates) — publish npm packages (with provenance) and PyPI `markdown-script` (OIDC trusted publishing + PEP 740 attestations, `skip-existing: true`). - 7. **github-release** — `gh release create` with generated notes; runs only + 8. **github-release** — `gh release create` with generated notes; runs only after all three publish jobs succeed. ## Post-release diff --git a/scripts/__test__/release-auth-probe.spec.mjs b/scripts/__test__/release-auth-probe.spec.mjs index 3e17ae44..d15ef097 100644 --- a/scripts/__test__/release-auth-probe.spec.mjs +++ b/scripts/__test__/release-auth-probe.spec.mjs @@ -23,6 +23,10 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + RELEASE_SURFACE, + TIER_B_EXPECTED_SKIPPED, +} from '../verify-pr-checks.mjs'; const ROOT = resolve(fileURLToPath(import.meta.url), '../../..'); const RELEASE_YML = join(ROOT, '.github/workflows/release.yml'); @@ -226,3 +230,227 @@ describe('security-08: npm auth probe in version-gate', () => { }); }); + +// --------------------------------------------------------------------------- +// Additional structural helpers for B1 checks (S5-S13) +// --------------------------------------------------------------------------- + +/** + * Extract the `on:` block — from `on:` (0-indent) to the next 0-indent key. + */ +function extractOnBlock(source) { + const lines = source.split('\n'); + const start = lines.findIndex(l => /^on:\s*$/.test(l)); + if (start === -1) return null; + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + if (/^[a-z]/.test(lines[i])) { end = i; break; } + } + return lines.slice(start, end).join('\n'); +} + +/** + * Extract the job-level `if:` condition (4-space indent). + * Returns the condition string, or null when none is present. + */ +function extractJobIf(jobSection) { + if (!jobSection) return null; + for (const line of jobSection.split('\n')) { + const m = /^ if:\s+(.+)$/.exec(line); + if (m) return m[1].trim(); + } + return null; +} + +// --------------------------------------------------------------------------- +// B1 structural invariants (S5-S13) +// --------------------------------------------------------------------------- + +describe('B1: release-surface PR gate and rehearsal jobs', () => { + + // ------------------------------------------------------------------------- + // S5: rehearse-publish-python job must exist. + // Non-vacuity: without this job the D-PR6 release-surface check would never + // be exercised on PRs, defeating PF-039 (tag-guarded steps are untested). + // ------------------------------------------------------------------------- + test('S5: rehearse-publish-python job exists in release.yml', () => { + const section = extractJobSection(yml, 'rehearse-publish-python'); + assert.ok( + section !== null, + 'rehearse-publish-python job must exist; PF-039 — a tag-guarded publish step ' + + 'that is never rehearsed on PRs cannot be validated before the release', + ); + }); + + // ------------------------------------------------------------------------- + // S6: rehearse-publish-python must have NO job-level if: guard. + // The job must run on pull_request, workflow_dispatch, AND tag push so that + // the OIDC exchange is validated before any irreversible crates.io publish. + // A job-level tag guard would re-introduce PF-039 for this job. + // ------------------------------------------------------------------------- + test('S6: rehearse-publish-python has no job-level if: (runs on PR, dispatch, and tag push)', () => { + const section = extractJobSection(yml, 'rehearse-publish-python'); + assert.ok(section !== null, 'rehearse-publish-python must exist (see S5)'); + const jobIf = extractJobIf(section); + assert.equal( + jobIf, null, + 'rehearse-publish-python must have NO job-level if: guard — it must run on all ' + + 'triggering events (pull_request, workflow_dispatch, push) so PRs exercise the ' + + 'OIDC exchange before the irreversible crates.io publish (PF-039); ' + + `got if: ${jobIf}`, + ); + }); + + // ------------------------------------------------------------------------- + // S7: rehearse-publish-python must transitively need version-gate. + // This ensures the credential probe runs before the OIDC exchange contacts + // the registry, preserving the security-08 ordering invariant. + // ------------------------------------------------------------------------- + test('S7: rehearse-publish-python transitively needs version-gate (ordering invariant)', () => { + const graph = buildNeedsGraph(yml); + assert.ok( + graph.has('rehearse-publish-python'), + 'rehearse-publish-python must be in the jobs graph (see S5)', + ); + assert.ok( + transitivelyNeeds(graph, 'rehearse-publish-python', 'version-gate'), + 'rehearse-publish-python must transitively need version-gate so the credential ' + + 'probe runs before the OIDC exchange; ' + + `direct needs: [${(graph.get('rehearse-publish-python') ?? []).join(', ')}]`, + ); + }); + + // ------------------------------------------------------------------------- + // S8: version-gate must contain a GHCR manifest probe for the + // pypa/gh-action-pypi-publish pin (PF-040, #350 option 3). + // An absent or stale pin (SHA instead of tag name) silently breaks the + // publish-python job at runtime with "manifest unknown". + // ------------------------------------------------------------------------- + test('S8: version-gate contains GHCR manifest probe for pypa/gh-action-pypi-publish', () => { + const section = extractJobSection(yml, 'version-gate'); + assert.ok(section !== null, 'version-gate must exist'); + assert.ok( + section.includes('ghcr.io/v2/pypa/gh-action-pypi-publish/manifests'), + 'version-gate must probe the GHCR manifest for the pypa/gh-action-pypi-publish ' + + 'pin (#350 option 3, PF-040); a missing or SHA-pinned image causes publish-python ' + + 'to fail at runtime with "manifest unknown"; ' + + `got section (first 600 chars):\n${section.slice(0, 600)}`, + ); + }); + + // ------------------------------------------------------------------------- + // S9: The CI-history step in version-gate must use a step-level if: guard + // (not a job-level guard). + // + // If version-gate itself were guarded at the job level to skip on PRs, the + // Tier-B verifier would see version-gate as skipped and fail the PR + // (ADR-013 amendment 2026-09-06). The fix is a step-level if: so the job + // runs (and succeeds) but the CI-history step is skipped on non-tag events. + // ------------------------------------------------------------------------- + test('S9: CI-history step in version-gate uses step-level if: (not job-level)', () => { + const section = extractJobSection(yml, 'version-gate'); + assert.ok(section !== null, 'version-gate must exist'); + + // Confirm the CI-history step still exists (non-vacuity). + assert.ok( + section.includes('Assert tagged SHA has green CI history'), + 'version-gate must still contain the CI-history step (non-vacuity guard)', + ); + + // Step-level if: is at 8-space indent (step field); job-level at 4-space. + // We accept step-level if: anywhere in the section (the step is the only + // consumer of a conditional skip in version-gate). + const hasStepLevelIf = section.split('\n').some(l => /^ if:/.test(l)); + assert.ok( + hasStepLevelIf, + 'version-gate must use a step-level if: (8-space indent) on the CI-history step ' + + 'so that PRs can skip that step without marking version-gate itself skipped — a ' + + 'skipped version-gate would fail the Tier-B verifier (ADR-013 amendment 2026-09-06); ' + + `got section (first 800 chars):\n${section.slice(0, 800)}`, + ); + }); + + // ------------------------------------------------------------------------- + // S10: The on: block must include a pull_request trigger with at least one + // path from RELEASE_SURFACE (ensuring release-surface PRs exercise the gate). + // ------------------------------------------------------------------------- + test('S10: on: block includes pull_request trigger with RELEASE_SURFACE paths', () => { + const onBlock = extractOnBlock(yml); + assert.ok(onBlock !== null, 'release.yml must have an on: block'); + assert.ok( + onBlock.includes('pull_request:'), + 'release.yml must have a pull_request: trigger so release-surface PRs are validated; ' + + `got on: block:\n${onBlock}`, + ); + // At least one RELEASE_SURFACE entry must appear in the on: block paths list. + const hasPath = RELEASE_SURFACE.some(entry => { + const base = entry.endsWith('/**') ? entry.slice(0, -3) : entry; + return onBlock.includes(base); + }); + assert.ok( + hasPath, + 'pull_request trigger paths must include at least one entry from RELEASE_SURFACE; ' + + `RELEASE_SURFACE = [\n ${RELEASE_SURFACE.join(',\n ')}\n]; ` + + `got on: block:\n${onBlock}`, + ); + }); + + // ------------------------------------------------------------------------- + // S11: publish-testpypi job must exist (opt-in TestPyPI leg, #350). + // ------------------------------------------------------------------------- + test('S11: publish-testpypi job exists in release.yml', () => { + const section = extractJobSection(yml, 'publish-testpypi'); + assert.ok( + section !== null, + 'publish-testpypi job must exist for the opt-in TestPyPI leg (#350)', + ); + }); + + // ------------------------------------------------------------------------- + // S12: publish-testpypi must be guarded by a job-level if: that references + // inputs.testpypi so it only runs when the dispatch input is set to true. + // Without this guard it would run on every PR and tag push, causing + // unintended TestPyPI uploads. + // ------------------------------------------------------------------------- + test('S12: publish-testpypi has job-level if: referencing inputs.testpypi', () => { + const section = extractJobSection(yml, 'publish-testpypi'); + assert.ok(section !== null, 'publish-testpypi must exist (see S11)'); + const jobIf = extractJobIf(section); + assert.ok( + jobIf !== null && jobIf.includes('inputs.testpypi'), + 'publish-testpypi must have a job-level if: referencing inputs.testpypi; ' + + 'this keeps it out of PR and standard dispatch runs — it runs ONLY when the ' + + 'workflow_dispatch testpypi input is true (#350); ' + + `got if: ${jobIf}`, + ); + }); + + // ------------------------------------------------------------------------- + // S13: publish-testpypi's name: field must appear in TIER_B_EXPECTED_SKIPPED + // so the Tier-B verifier tolerates the skipped conclusion on standard PRs + // and dispatch runs (ADR-013). + // ------------------------------------------------------------------------- + test('S13: publish-testpypi name is listed in TIER_B_EXPECTED_SKIPPED', () => { + const section = extractJobSection(yml, 'publish-testpypi'); + assert.ok(section !== null, 'publish-testpypi must exist (see S11)'); + + // Extract the job name: field (4-space indent). + let jobName = null; + for (const line of section.split('\n')) { + const m = /^ name:\s+(.+)$/.exec(line); + if (m) { jobName = m[1].trim(); break; } + } + assert.ok( + jobName !== null, + `publish-testpypi must have a name: field; got section:\n${section}`, + ); + + assert.ok( + TIER_B_EXPECTED_SKIPPED.has(jobName), + `publish-testpypi name "${jobName}" must be listed in TIER_B_EXPECTED_SKIPPED ` + + `in verify-pr-checks.mjs so skipped conclusions are tolerated on non-dispatch runs ` + + `(ADR-013); current set: [${[...TIER_B_EXPECTED_SKIPPED].join(', ')}]`, + ); + }); + +}); diff --git a/scripts/__test__/verify-pr-checks.spec.mjs b/scripts/__test__/verify-pr-checks.spec.mjs index 41c96e3c..dcdfeb75 100644 --- a/scripts/__test__/verify-pr-checks.spec.mjs +++ b/scripts/__test__/verify-pr-checks.spec.mjs @@ -27,6 +27,9 @@ import { parseGhStderrHttpStatus, EXPECTED_CONTEXTS, TIER_B_EXPECTED_SKIPPED, + RELEASE_SURFACE, + RELEASE_SURFACE_CONTEXTS, + matchesReleaseSurface, } from '../verify-pr-checks.mjs'; const ROOT = resolve(fileURLToPath(import.meta.url), '../../..'); @@ -647,7 +650,9 @@ describe('AC-26 AC-28 AC-29: live path exit codes (injected runner)', () => { // CPU cost and any unexpected loops — network latency is zero. assert.ok(elapsed < 15000, `verifier must complete in < 15 s wall-clock (AC-30 clause b); took ${elapsed}ms`); - assert.equal(calls.length, 4, `expected 4 API calls (pr, protection, checks, status); got ${calls.length}`); + // D-PR6: adds one PR-files call (pulls/{n}/files?per_page=100) for the release-surface check. + // The /pulls/ stub also matches the files URL and returns PR_OK (no .files → empty list → ok). + assert.equal(calls.length, 5, `expected 5 API calls (pr, protection, checks, status, files); got ${calls.length}`); const checkCall = calls.find(u => u.includes('/check-runs')); assert.ok(checkCall.includes('filter=latest'), 'filter=latest must be pinned explicitly (D-PR4a)'); // D-PR4a parity: combined-status endpoint must request per_page=100 so a context @@ -1420,10 +1425,11 @@ describe('architecture-08: fetchCheckRuns — bounded loop and total_count guard // --------------------------------------------------------------------------- // TIER_B_EXPECTED_SKIPPED: release publish jobs may be skipped on a PR-branch -// dry-run (guarded by startsWith(github.ref, 'refs/tags/v') in release.yml). -// Only 'Publish to crates.io', 'Publish to npm', 'Publish to PyPI', 'GitHub -// Release', only when 'skipped', pass Tier B. Any other conclusion or any -// other name still fails. +// dry-run (guarded by startsWith(github.ref,'refs/tags/v') or the testpypi +// dispatch input). Five names: 'Publish to crates.io', 'Publish to npm', +// 'Publish to PyPI', 'GitHub Release', 'Publish to TestPyPI (rehearsal)'; +// only when 'skipped', pass Tier B. Any other conclusion or any other name +// still fails. (ADR-013 amendment 2026-09-06) // --------------------------------------------------------------------------- describe('TIER_B_EXPECTED_SKIPPED: release dry-run skipped publish jobs', () => { @@ -1553,6 +1559,192 @@ describe('TIER_B_EXPECTED_SKIPPED: release dry-run skipped publish jobs', () => ); }); + test('D-PR5f: "Publish to TestPyPI (rehearsal)" with conclusion=cancelled → FAIL (only skipped is allowed)', () => { + // ADR-013 amendment: this job is added to TIER_B_EXPECTED_SKIPPED so that + // a skipped run (dispatch-input-guarded) does not block the verifier. + // But cancelled is NOT skipped — it is a real anomaly and must fail closed. + const runs = basePassingRunsWith([ + { name: 'Publish to TestPyPI (rehearsal)', status: 'completed', conclusion: 'cancelled' }, + ]); + const result = evaluateChecks({ + requiredContexts: REQUIRED, + checkRuns: runs, + statuses: [], + headSha: HEAD_113F472, + }); + assert.equal(result.exitCode, 1, + 'cancelled Publish to TestPyPI must exit 1 (only skipped is allowed in TIER_B_EXPECTED_SKIPPED)'); + const allLines = result.lines.join('\n'); + assert.ok(allLines.includes('Publish to TestPyPI'), `must name the failing job; got:\n${allLines}`); + assert.ok(allLines.includes('cancelled'), `must quote the conclusion; got:\n${allLines}`); + }); + +}); + +// --------------------------------------------------------------------------- +// D-PR6: Release-surface presence check +// +// When a PR touches .github/workflows/release.yml, .github/actions/**, +// crates/mds-napi/**, crates/mds-python/**, or scripts/verify-napi-names.mjs, +// the verifier REQUIRES completed+success runs for each RELEASE_SURFACE_CONTEXTS +// job: "Version gate", "Stage + verify platform packages", +// "Rehearse PyPI publish (no upload)". +// --------------------------------------------------------------------------- +describe('D-PR6: release-surface presence check', () => { + + // Helper: all required + expected passes; inject extras. + function passingRunsWith(extras) { + return [ + ...loadCheckRuns('checks-main-113f472.json'), + { ...SOURCE_HYGIENE_PASS }, + ...extras, + ]; + } + + // Three release-surface jobs, all success. + function releaseSuccessRuns() { + return RELEASE_SURFACE_CONTEXTS.map(name => ({ + name, + status: 'completed', + conclusion: 'success', + })); + } + + test('D-PR6a: changedFiles touches release surface + all RELEASE_SURFACE_CONTEXTS present/success → exit 0 and "release surface touched" line', () => { + const checkRuns = passingRunsWith(releaseSuccessRuns()); + const result = evaluateChecks({ + requiredContexts: REQUIRED, + checkRuns, + statuses: [], + headSha: HEAD_113F472, + changedFiles: ['.github/workflows/release.yml'], + }); + assert.equal(result.exitCode, 0, + `touched release surface + all contexts success must exit 0; lines:\n${result.lines.join('\n')}`); + const allLines = result.lines.join('\n'); + assert.ok( + allLines.includes('release surface touched'), + `output must include "release surface touched"; got:\n${allLines}`, + ); + }); + + test('D-PR6b: touched + "Version gate" absent → exit 1 naming it (#342)', () => { + const withoutVersionGate = releaseSuccessRuns().filter(r => r.name !== 'Version gate'); + const checkRuns = passingRunsWith(withoutVersionGate); + const result = evaluateChecks({ + requiredContexts: REQUIRED, + checkRuns, + statuses: [], + headSha: HEAD_113F472, + changedFiles: ['.github/workflows/release.yml'], + }); + assert.equal(result.exitCode, 1, + `absent "Version gate" with release surface touched must exit 1; got:\n${result.lines.join('\n')}`); + const allLines = result.lines.join('\n'); + assert.ok(allLines.includes('Version gate'), `must name the absent job; got:\n${allLines}`); + }); + + test('D-PR6c: touched + "Rehearse PyPI publish (no upload)" completed+skipped → exit 1', () => { + const withSkippedRehearse = releaseSuccessRuns().map(r => + r.name === 'Rehearse PyPI publish (no upload)' + ? { ...r, conclusion: 'skipped' } + : r, + ); + const checkRuns = passingRunsWith(withSkippedRehearse); + const result = evaluateChecks({ + requiredContexts: REQUIRED, + checkRuns, + statuses: [], + headSha: HEAD_113F472, + changedFiles: ['.github/workflows/release.yml'], + }); + assert.equal(result.exitCode, 1, + 'release surface requires success, not skipped; must exit 1'); + const allLines = result.lines.join('\n'); + assert.ok( + allLines.includes('Rehearse PyPI publish (no upload)'), + `must name the non-success context; got:\n${allLines}`, + ); + }); + + test('D-PR6d: touched + one present run in_progress → exit 1', () => { + const withInProgress = releaseSuccessRuns().map(r => + r.name === 'Stage + verify platform packages' + ? { name: r.name, status: 'in_progress', conclusion: null } + : r, + ); + const checkRuns = passingRunsWith(withInProgress); + const result = evaluateChecks({ + requiredContexts: REQUIRED, + checkRuns, + statuses: [], + headSha: HEAD_113F472, + changedFiles: ['crates/mds-napi/src/lib.rs'], + }); + assert.equal(result.exitCode, 1, 'in_progress release job with touched surface must exit 1'); + }); + + test('D-PR6e: changedFiles = ["crates/mds-core/src/lib.rs"], no release runs → exit 0 and "not touched" line', () => { + const checkRuns = passingRunsWith([]); + const result = evaluateChecks({ + requiredContexts: REQUIRED, + checkRuns, + statuses: [], + headSha: HEAD_113F472, + changedFiles: ['crates/mds-core/src/lib.rs'], + }); + assert.equal(result.exitCode, 0, + 'non-release file must not require release check-runs; must exit 0'); + const allLines = result.lines.join('\n'); + assert.ok( + allLines.includes('release surface not touched'), + `must include "release surface not touched"; got:\n${allLines}`, + ); + }); + + test('D-PR6f: matchesReleaseSurface positives and negatives', () => { + // Positives (must return true) + assert.ok(matchesReleaseSurface('.github/actions/setup-wasm/action.yml'), + '.github/actions/** pattern must match .github/actions/setup-wasm/action.yml'); + assert.ok(matchesReleaseSurface('crates/mds-napi/src/lib.rs'), + 'crates/mds-napi/** must match crates/mds-napi/src/lib.rs'); + assert.ok(matchesReleaseSurface('scripts/verify-napi-names.mjs'), + 'exact match must work for scripts/verify-napi-names.mjs'); + assert.ok(matchesReleaseSurface('.github/workflows/release.yml'), + 'exact match must work for .github/workflows/release.yml'); + assert.ok(matchesReleaseSurface('crates/mds-python/src/lib.rs'), + 'crates/mds-python/** must match crates/mds-python/src/lib.rs'); + + // Negatives (must return false) + assert.ok(!matchesReleaseSurface('.github/workflows/ci.yml'), + '.github/workflows/ci.yml must NOT match (only release.yml is listed)'); + assert.ok(!matchesReleaseSurface('scripts/verify-versions.mjs'), + 'scripts/verify-versions.mjs must NOT match (only verify-napi-names.mjs is listed)'); + assert.ok(!matchesReleaseSurface('crates/mds-core/src/lib.rs'), + 'crates/mds-core/** must NOT match (not in RELEASE_SURFACE)'); + }); + + test('D-PR6g: changedFiles undefined → exit 0 with skip notice (changedFiles is optional)', () => { + // When main() cannot fetch the changed-files list (API error), it passes + // changedFiles=undefined. evaluateChecks must skip the D-PR6 check and + // include a notice so the operator knows the check was skipped. + const checkRuns = passingRunsWith([]); + const result = evaluateChecks({ + requiredContexts: REQUIRED, + checkRuns, + statuses: [], + headSha: HEAD_113F472, + changedFiles: undefined, + }); + assert.equal(result.exitCode, 0, 'undefined changedFiles must not cause failure'); + const allLines = result.lines.join('\n'); + assert.ok( + allLines.includes('release-surface presence check skipped') || + allLines.includes('changedFiles not provided'), + `must include a skip notice; got:\n${allLines}`, + ); + }); + }); // Code of Conduct tests (AC-1, AC-2) live in code-of-conduct.spec.mjs — diff --git a/scripts/verify-pr-checks.mjs b/scripts/verify-pr-checks.mjs index 15733d04..e02cfc05 100644 --- a/scripts/verify-pr-checks.mjs +++ b/scripts/verify-pr-checks.mjs @@ -124,19 +124,83 @@ export const EXPECTED_CONTEXTS = [ ]; // Tier B allowance (release pre-flight): release.yml's publish jobs are -// guarded by startsWith(github.ref, 'refs/tags/v'), so the RELEASING.md -// dry-run dispatched on a PR branch reports them on the PR head as -// conclusion=skipped. That skip IS the guard working, not a missing -// verification. Only these four names, only when 'skipped', pass Tier B; -// any other conclusion (cancelled, failure, neutral, null) still fails, and a -// skipped run under any other name still fails. +// guarded by startsWith(github.ref, 'refs/tags/v') (or the testpypi dispatch +// input), so the RELEASING.md dry-run dispatched on a PR branch reports them +// on the PR head as conclusion=skipped. That skip IS the guard working, not a +// missing verification. Only these five names, only when 'skipped', pass Tier +// B; any other conclusion (cancelled, failure, neutral, null) still fails, and +// a skipped run under any other name still fails. +// ADR-013 amendment (2026-09-06): 'Publish to TestPyPI (rehearsal)' is +// dispatch-input-guarded — skipped everywhere except `workflow_dispatch -f +// testpypi=true`. Adding it to this set so the mandatory pre-merge verifier +// does not hard-fail on PRs that dispatch a release dry-run. export const TIER_B_EXPECTED_SKIPPED = new Set([ 'Publish to crates.io', 'Publish to npm', 'Publish to PyPI', 'GitHub Release', + 'Publish to TestPyPI (rehearsal)', ]); +// --------------------------------------------------------------------------- +// D-PR6: Release-surface presence check +// +// When a PR touches the release surface (paths matching release.yml's +// pull_request.paths filter), the verifier REQUIRES a completed+success run +// from each job in RELEASE_SURFACE_CONTEXTS. This closes the gap: a +// mis-specified paths filter could pass as a silent no-run (ADR-013 §3). +// +// RELEASE_SURFACE must equal the workflow's on.pull_request.paths list +// exactly. Spec S12 (release-auth-probe.spec.mjs) enforces that constraint. +// --------------------------------------------------------------------------- + +/** + * The set of paths that trigger release.yml on pull_request. + * Must equal the `on.pull_request.paths:` list in .github/workflows/release.yml + * (enforced by spec S12 in release-auth-probe.spec.mjs). + * + * Pattern rules: a pattern ending in `/**` matches any file under that prefix; + * all other patterns are exact file path equality (no glob library needed). + */ +export const RELEASE_SURFACE = [ + '.github/workflows/release.yml', + '.github/actions/**', + 'crates/mds-napi/**', + 'crates/mds-python/**', + 'scripts/verify-napi-names.mjs', +]; + +/** + * Job display-names in release.yml that must be completed+success on a PR + * that touches the release surface. Matrix legs are implied by Stage's needs. + * These are Tier A semantics applied to release check-runs (presence required). + */ +export const RELEASE_SURFACE_CONTEXTS = [ + 'Version gate', + 'Stage + verify platform packages', + 'Rehearse PyPI publish (no upload)', +]; + +/** + * Returns true when `file` matches any pattern in RELEASE_SURFACE. + * A pattern ending in `/**` matches any file under that directory prefix. + * All other patterns require exact equality. + * + * @param {string} file - a changed file path (e.g. 'crates/mds-napi/src/lib.rs') + * @returns {boolean} + */ +export function matchesReleaseSurface(file) { + for (const pattern of RELEASE_SURFACE) { + if (pattern.endsWith('/**')) { + const prefix = pattern.slice(0, -3) + '/'; + if (file.startsWith(prefix)) return true; + } else if (file === pattern) { + return true; + } + } + return false; +} + // --------------------------------------------------------------------------- // gh runner (thin IO shim; injected in tests for offline operation) // --------------------------------------------------------------------------- @@ -226,6 +290,7 @@ function defaultGhRunner(args) { * headSha: string; * prNumber?: number; // included in the emitted merge command (D-PR5) * expectedContexts?: string[]; // defaults to EXPECTED_CONTEXTS + * changedFiles?: string[]; // D-PR6: when present, release-surface presence check runs * }} EvaluateInput * * @typedef {{ @@ -253,6 +318,7 @@ export function evaluateChecks({ headSha, prNumber, expectedContexts = EXPECTED_CONTEXTS, + changedFiles, }) { const lines = []; const failures = []; @@ -473,6 +539,50 @@ export function evaluateChecks({ } } + // ---- D-PR6: release-surface presence check ---- + // When changedFiles is provided and any file matches the release surface, + // require each RELEASE_SURFACE_CONTEXTS job to be present and success. + // Absence or non-success is FAIL (Tier A semantics applied to release runs). + // When changedFiles is undefined, skip — callers that only have a SHA cannot + // enumerate files without an additional API call. + if (changedFiles === undefined) { + lines.push( + ' · D-PR6: changedFiles not provided — release-surface presence check skipped ' + + '(caller would need a PR number to enumerate changed files)', + ); + } else { + const touchedFiles = changedFiles.filter(f => matchesReleaseSurface(f)); + if (touchedFiles.length > 0) { + lines.push( + ` release surface touched (${touchedFiles.length} file(s): ${touchedFiles.slice(0, 5).join(', ')}` + + (touchedFiles.length > 5 ? ', …' : '') + + ') — requiring release check-runs', + ); + for (const ctx of RELEASE_SURFACE_CONTEXTS) { + const releaseRuns = checkRuns.filter(cr => cr.name === ctx); + if (releaseRuns.length === 0) { + failures.push( + `D-PR6 (release surface): "${ctx}" absent — the PR touches the release ` + + `surface but release.yml's pull_request run is missing, not finished, or failed (#342)`, + ); + pass = false; + } else { + for (const cr of releaseRuns) { + if (cr.status !== 'completed' || cr.conclusion !== 'success') { + failures.push( + `D-PR6 (release surface): "${ctx}" — status=${cr.status}, conclusion=${cr.conclusion ?? 'null'} ` + + `— the PR touches the release surface but release.yml's pull_request run is missing, not finished, or failed (#342)`, + ); + pass = false; + } + } + } + } + } else { + lines.push(' release surface not touched — no release run required'); + } + } + // ---- Compose result ---- for (const f of failures) { lines.push(`✖ ${f}`); @@ -782,6 +892,56 @@ export function main(argv = process.argv.slice(2), runner = defaultGhRunner, ghV return st.exitCode; } + // ---- D-PR6: fetch changed files for release-surface presence check ---- + // Paginated with the same bounded pattern as fetchCheckRuns (D-PR4a). + // If the API call fails we degrade gracefully (skip the check) rather than + // hard-failing — the changed-files API is best-effort context, not a gate. + // Non-vacuity: assert collected count equals PR's changed_files (exit 2 on + // shortfall so a partial set is never silently evaluated as complete). + const MAX_FILES_PAGES = 30; + let changedFiles; + { + const declaredCount = prData.changed_files ?? null; + const collected = []; + let filesPage = 1; + let filesFailed = false; + let filesPagError = null; + + while (filesPage <= MAX_FILES_PAGES) { + const filesUrl = + `/repos/{owner}/{repo}/pulls/${prNumber}/files?per_page=100&page=${filesPage}`; + const filesData = runner(['api', filesUrl]); + if (filesData.__error) { + filesFailed = true; + filesPagError = `PR files API error (page ${filesPage}): ${filesData.stderr}`; + break; + } + const page = Array.isArray(filesData) ? filesData : (filesData.files ?? []); + collected.push(...page.map(f => f.filename)); + if (page.length < 100) break; + filesPage++; + } + + if (!filesFailed && filesPage > MAX_FILES_PAGES) { + fail(`PR files pagination exceeded ${MAX_FILES_PAGES} pages — refusing to evaluate partial release-surface result`); + return 2; + } + + if (filesFailed) { + console.log(` · D-PR6: changed-files API error — release-surface check skipped: ${filesPagError}`); + changedFiles = undefined; + } else if (declaredCount !== null && collected.length !== declaredCount) { + fail( + `collected ${collected.length} changed files but PR declares changed_files=${declaredCount} — ` + + `partial file list (D-PR6 non-vacuity, avoids PF-013)`, + ); + return 2; + } else { + changedFiles = collected; + console.log(` changed files: ${collected.length}`); + } + } + // ---- Evaluate (D-PR1: pure function) ---- const result = evaluateChecks({ requiredContexts: req.contexts, @@ -789,6 +949,7 @@ export function main(argv = process.argv.slice(2), runner = defaultGhRunner, ghV statuses: st.statuses, headSha, prNumber, + changedFiles, }); for (const line of result.lines) { From d5188787c40db583ace618ab530f5103ed518338 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 7 Sep 2026 00:15:30 +0300 Subject: [PATCH 2/6] refactor(scripts): extract fetchChangedFiles from main() inline block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The D-PR6 changed-files fetch was an inline block scope with four mutable let variables (filesFailed, filesPagError, filesPage, collected) and a confusing name collision — the loop data variable was also named `page`, shadowing the loop iterator `filesPage`. Extract fetchChangedFiles() following the same Result-return pattern as fetchCheckRuns() and fetchStatuses(): returns { ok: true, files } on success, { ok: 'skip', notice } on API error (graceful degradation), and { ok: false, exitCode: 2, message } on pagination overflow or non-vacuity mismatch. main() dispatches on filesResult.ok with explicit equality checks. No behaviour change. 172/0 pass/fail. --- scripts/verify-pr-checks.mjs | 108 ++++++++++++++++++++--------------- 1 file changed, 62 insertions(+), 46 deletions(-) diff --git a/scripts/verify-pr-checks.mjs b/scripts/verify-pr-checks.mjs index e02cfc05..00adf632 100644 --- a/scripts/verify-pr-checks.mjs +++ b/scripts/verify-pr-checks.mjs @@ -791,6 +791,56 @@ export function fetchRequiredContexts(baseBranch, requiredFrom, runner) { return { ok: true, contexts, resolvedBranch: branch, notes }; } +/** + * Fetch changed-file paths for a PR, paginated (D-PR6). + * + * Three outcomes: + * - API error → { ok: 'skip', notice: string } — degrade gracefully (best-effort) + * - Pagination overflow or count mismatch → { ok: false, exitCode: 2, message: string } + * - Success → { ok: true, files: string[] } + * + * @param {number|string} prNumber + * @param {number|null} declaredCount — PR's `changed_files` field, or null when absent + * @param {function} runner + */ +export function fetchChangedFiles(prNumber, declaredCount, runner) { + const MAX_FILES_PAGES = 30; + const files = []; + let page = 1; + + while (page <= MAX_FILES_PAGES) { + const url = `/repos/{owner}/{repo}/pulls/${prNumber}/files?per_page=100&page=${page}`; + const data = runner(['api', url]); + if (data.__error) { + return { ok: 'skip', notice: `PR files API error (page ${page}): ${data.stderr}` }; + } + const items = Array.isArray(data) ? data : (data.files ?? []); + files.push(...items.map(f => f.filename)); + if (items.length < 100) break; + page++; + } + + if (page > MAX_FILES_PAGES) { + return { + ok: false, + exitCode: 2, + message: `PR files pagination exceeded ${MAX_FILES_PAGES} pages — refusing to evaluate partial release-surface result`, + }; + } + + if (declaredCount !== null && files.length !== declaredCount) { + return { + ok: false, + exitCode: 2, + message: + `collected ${files.length} changed files but PR declares changed_files=${declaredCount} — ` + + `partial file list (D-PR6 non-vacuity, avoids PF-013)`, + }; + } + + return { ok: true, files }; +} + const USAGE = 'Usage: node scripts/verify-pr-checks.mjs [--required-from ]'; @@ -893,53 +943,19 @@ export function main(argv = process.argv.slice(2), runner = defaultGhRunner, ghV } // ---- D-PR6: fetch changed files for release-surface presence check ---- - // Paginated with the same bounded pattern as fetchCheckRuns (D-PR4a). - // If the API call fails we degrade gracefully (skip the check) rather than - // hard-failing — the changed-files API is best-effort context, not a gate. - // Non-vacuity: assert collected count equals PR's changed_files (exit 2 on - // shortfall so a partial set is never silently evaluated as complete). - const MAX_FILES_PAGES = 30; + // API errors degrade gracefully (skip the check); pagination overflow and + // count mismatches fail closed (exit 2, non-vacuity — avoids PF-013). + const filesResult = fetchChangedFiles(prNumber, prData.changed_files ?? null, runner); let changedFiles; - { - const declaredCount = prData.changed_files ?? null; - const collected = []; - let filesPage = 1; - let filesFailed = false; - let filesPagError = null; - - while (filesPage <= MAX_FILES_PAGES) { - const filesUrl = - `/repos/{owner}/{repo}/pulls/${prNumber}/files?per_page=100&page=${filesPage}`; - const filesData = runner(['api', filesUrl]); - if (filesData.__error) { - filesFailed = true; - filesPagError = `PR files API error (page ${filesPage}): ${filesData.stderr}`; - break; - } - const page = Array.isArray(filesData) ? filesData : (filesData.files ?? []); - collected.push(...page.map(f => f.filename)); - if (page.length < 100) break; - filesPage++; - } - - if (!filesFailed && filesPage > MAX_FILES_PAGES) { - fail(`PR files pagination exceeded ${MAX_FILES_PAGES} pages — refusing to evaluate partial release-surface result`); - return 2; - } - - if (filesFailed) { - console.log(` · D-PR6: changed-files API error — release-surface check skipped: ${filesPagError}`); - changedFiles = undefined; - } else if (declaredCount !== null && collected.length !== declaredCount) { - fail( - `collected ${collected.length} changed files but PR declares changed_files=${declaredCount} — ` + - `partial file list (D-PR6 non-vacuity, avoids PF-013)`, - ); - return 2; - } else { - changedFiles = collected; - console.log(` changed files: ${collected.length}`); - } + if (filesResult.ok === false) { + fail(filesResult.message); + return filesResult.exitCode; + } else if (filesResult.ok === 'skip') { + console.log(` · D-PR6: changed-files API error — release-surface check skipped: ${filesResult.notice}`); + changedFiles = undefined; + } else { + changedFiles = filesResult.files; + console.log(` changed files: ${changedFiles.length}`); } // ---- Evaluate (D-PR1: pure function) ---- From df9ae24a7a942b3249a9ac37becf00b8e9af9599 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 7 Sep 2026 00:33:59 +0300 Subject: [PATCH 3/6] fix(release): rehearsal must never invoke gh-action-pypi-publish; fail closed on the PR-files API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 (Functionality/Security, PF-039): rehearse-publish-python invoked `pypa/gh-action-pypi-publish@v1.14.2` with `dry-run: true`. That input does not exist in v1.14.2 — the runner logged "Unexpected input(s) 'dry-run'", the action ignored it and performed a REAL upload to https://upload.pypi.org/legacy/ from a pull_request (run 34060146952). It failed only because the workspace version (0.4.2) was already on PyPI; on a version-bump PR — which touches crates/mds-python/Cargo.toml and therefore matches the release-surface paths filter — the upload would have SUCCEEDED, publishing an unreleased version from an unmerged branch. pypi.org still lists 0.4.2 only; nothing landed. The action has no no-upload mode, so the rehearsal now REPRODUCES what it does instead of calling it: pin shape, GHCR manifest, docker pull, and `twine check` run from the pinned image with `--network none --entrypoint twine`. Each gate carries a positive control (PF-013): the v0.4.1 annotated-tag-object SHA must be rejected, a ref that cannot exist must not resolve or pull, and a corrupt wheel must fail twine. The job is denied id-token, so it holds no credential to upload with even if a step is re-introduced. The duplicate GHCR probe in version-gate is removed — publish-crates needs the rehearsal, so a bad pin still aborts before the irreversible crates.io write (PF-023, PF-040). P1 (Error Handling, PF-013): fetchChangedFiles returned `{ ok: 'skip' }` on an API error and main() then skipped the release-surface presence check — a transient 500 would let a release-surface PR pass the merge gate with no release check-runs. It now fails closed (exit 2), matching fetchCheckRuns/fetchStatuses/ fetchRequiredContexts; the tri-state is gone. Only the exported pure function's `changedFiles === undefined` caller-path still skips, with a notice. P1 (Functionality, ADR-013 amendment): the credential and PyPI OIDC probes are skipped at STEP level on pull_request. Fork and Dependabot PRs receive no repository secrets and no id-token, so both probes hard-failed version-gate — on exactly the Dependabot-bump PR class #350 exists to catch (PF-039 amendment 2026-09-05), and a version-gate that cannot reach success hard-fails the mandatory pre-merge verifier, whose natural workaround is bypassing it (PF-017). P1 (Tests): S14 pins the invocation to publish-python/publish-testpypi only (with a planted positive control and a commented-out counter-control), S15 the absent id-token, S16 PIN_REF/uses drift, S17 the four gates and their controls. S8 follows the probe to the rehearsal. S10 now compares on.pull_request.paths to RELEASE_SURFACE as SETS — the invariant the production comment already claimed but nothing enforced. S13 gains a positive control. D-PR6h drives the fail-closed path end-to-end through main() (verified failing against the old behaviour), D-PR6i pins the prefix/exact-match boundaries, D-PR6j pins all-must-pass for duplicate names. S18 pins every RELEASE_SURFACE_CONTEXTS name to a real job name: in release.yml — a renamed job would otherwise make the verifier demand a check-run that can never appear, hard-failing every release-surface PR (ADR-013 amendment, PF-017). npm run test:gates: 183 tests, 183 pass, 0 fail (was 172). --- .github/workflows/release.yml | 239 ++++++++++-- CHANGELOG.md | 2 +- RELEASING.md | 98 +++-- scripts/__test__/release-auth-probe.spec.mjs | 369 +++++++++++++++++-- scripts/__test__/verify-pr-checks.spec.mjs | 139 ++++++- scripts/verify-pr-checks.mjs | 55 ++- 6 files changed, 793 insertions(+), 109 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 821c2377..0a7ee384 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,7 +55,19 @@ jobs: ref: ${{ github.ref }} - uses: actions/setup-node@v7 with: { node-version: 22, registry-url: "https://registry.npmjs.org" } + # Skipped on pull_request (step-level, not job-level — ADR-013 amendment + # 2026-09-06: a job present on a PR head must reach conclusion=success or + # the mandatory pre-merge verifier hard-fails the very PR that changes the + # release surface, and the operator's next move is to bypass the verifier, + # which is the PF-017 shape). Fork and Dependabot pull_request events + # receive NO repository secrets and no id-token: write, so these probes + # CANNOT pass there — and a Dependabot bump to a publish-python action is + # precisely the PR class #350 exists to catch (PF-039 amendment + # 2026-09-05). Skipping loses nothing: no pull_request run can reach a + # registry write. The probes still run, and still fail closed, on tag push + # and workflow_dispatch — the only events that publish. - name: "Verify publish credentials before irreversible steps (security-08)" + if: github.event_name != 'pull_request' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} CARGO_REG_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} @@ -87,7 +99,11 @@ jobs: # NOT tag-guarded: this step must run in the workflow_dispatch dry run # (PF-039) so a misconfigured or expired trusted publisher is caught BEFORE # any irreversible crates.io publish. version-gate has id-token: write above. + # Skipped on pull_request for the same reason as the credential probe: a + # fork PR's GITHUB_TOKEN is read-only regardless of the permissions block, + # so ACTIONS_ID_TOKEN_REQUEST_URL is unset and the step cannot succeed. - name: "Verify PyPI trusted publisher before irreversible steps (security-08)" + if: github.event_name != 'pull_request' shell: bash run: | set -euo pipefail @@ -101,29 +117,14 @@ jobs: echo "::error::PyPI OIDC mint failed (HTTP $CODE) — trusted publisher missing, expired, or mismatched. Fix BEFORE tagging; crates.io is irreversible." jq -r '.message // .' /tmp/mint.json; exit 1; } echo "PyPI trusted publisher OK" - # PF-040: verify the pypa/gh-action-pypi-publish tag pin resolves in GHCR - # before any irreversible publish. The action derives its GHCR image tag - # from github.action_ref — a commit-SHA or annotated-tag-object-SHA pin - # causes "manifest unknown" because GHCR only publishes images for release - # tag names. This probe catches a bad pin (SHA instead of tag name) at - # version-gate time, before the build matrix starts. Anonymous REST; no - # Docker CLI needed. Runs on all triggers (PF-039). - - name: "Probe GHCR: verify pypa/gh-action-pypi-publish pin resolves (PF-040, #350)" - shell: bash + - name: "Credential + OIDC probes skipped on pull_request (step-level guard — ADR-013 amendment)" + if: github.event_name == 'pull_request' run: | - set -euo pipefail - PIN_REF=v1.14.2 - TOKEN=$(curl -sf \ - 'https://ghcr.io/token?scope=repository:pypa/gh-action-pypi-publish:pull' \ - | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>process.stdout.write(JSON.parse(d).token))") - STATUS=$(curl -sf -o /dev/null -w '%{http_code}' \ - -H "Authorization: Bearer ${TOKEN}" \ - "https://ghcr.io/v2/pypa/gh-action-pypi-publish/manifests/${PIN_REF}") - if [ "$STATUS" != "200" ]; then - echo "::error::GHCR manifest probe failed — pypa/gh-action-pypi-publish:${PIN_REF} returned HTTP ${STATUS}. The pin in publish-python/publish-testpypi must be a release tag name, not a SHA (PF-040). If the tag was bumped, update PIN_REF here and the uses: pins in publish-python and publish-testpypi." - exit 1 - fi - echo "GHCR probe OK: pypa/gh-action-pypi-publish:${PIN_REF} → HTTP ${STATUS}" + echo "::notice::Credential and PyPI OIDC probes skipped for pull_request events — fork and Dependabot PRs receive no repository secrets and no id-token, and no PR run can reach a registry write. Both probes run and fail closed on tag push and workflow_dispatch." + # The pypa/gh-action-pypi-publish pin is validated in rehearse-publish-python + # (pin shape, GHCR manifest, docker pull, twine check — each with a positive + # control). publish-crates needs that job, so a bad pin still aborts the + # release before the irreversible crates.io write (PF-040, PF-023). - name: "Assert synchronized versions, no file: refs" run: node scripts/verify-versions.mjs # #288: Source-hygiene gate — also runs on tag pushes via this job. @@ -695,22 +696,52 @@ jobs: # =========================================================================== # Rehearsal jobs — run on pull_request, workflow_dispatch, AND tag push. - # Intentionally unguarded (no if:) so PRs exercise the OIDC exchange before + # Intentionally unguarded (no if:) so PRs exercise the publish path before # any irreversible crates.io publish (PF-039: tag-guarded steps unexercised). # =========================================================================== # --------------------------------------------------------------------------- - # Rehearse the PyPI OIDC exchange + wheel shape without uploading. - # Runs on all three triggers. publish-crates blocks on this so a failed - # OIDC exchange aborts before the irreversible crates.io write. + # Rehearse publish-python WITHOUT uploading anything. + # + # publish-python is the one job no dry run can execute (PF-039): it is + # tag-guarded, and its payload — an upload to pypi.org — is irreversible and + # therefore unrehearsable by definition. What CAN be rehearsed is everything + # that has actually broken it: the SHAPE of the pypa/gh-action-pypi-publish + # pin, the GHCR image that pin resolves to, and the metadata of the + # distributions twine is asked to upload. The v0.4.1 break was a docker pull + # that failed before PyPI was contacted at all (PF-040). + # + # This job MUST NOT invoke pypa/gh-action-pypi-publish. That action has no + # dry-run / no-upload mode: an unrecognised `dry-run:` input is warned about + # and ignored, and the action then uploads for real. Run 34060146952 — a + # pull_request run of this workflow — did exactly that, and was saved only by + # the workspace version already existing on PyPI. So the rehearsal REPRODUCES + # what the action does (pull its image, run twine out of it) instead of + # calling it. Spec S14 pins that; S15 additionally withholds id-token so this + # job has no credential to upload with even if a step is re-introduced. + # + # Every gate below carries a POSITIVE CONTROL (PF-013): a known-bad input the + # gate must reject. A gate never observed rejecting anything is not evidence. # --------------------------------------------------------------------------- rehearse-publish-python: name: Rehearse PyPI publish (no upload) needs: [version-gate, stage-and-verify-napi, build-python] runs-on: ubuntu-latest permissions: - id-token: write # OIDC for PyPI trusted publisher dry-run + # Deliberately NO OIDC write permission here. A job that cannot mint a + # PyPI trusted-publishing credential cannot upload, even if an upload step + # is re-introduced by mistake — defence in depth behind spec S14/S15. contents: read + env: + # Single source of truth for the pin under test. Must equal the ref in the + # `uses: pypa/gh-action-pypi-publish@` lines in publish-python and + # publish-testpypi — spec S16 pins that equality, because a rehearsal that + # validates a different ref than the release pulls proves nothing (PF-040). + PIN_REF: v1.14.2 + PUBLISH_IMAGE: ghcr.io/pypa/gh-action-pypi-publish + # A ref that must never exist — the known-bad input every positive control + # below is exercised against. + BOGUS_REF: v0.0.0-mds-does-not-exist steps: - name: Download Python artifacts uses: actions/download-artifact@v8 @@ -718,13 +749,142 @@ jobs: pattern: python-* path: python-dist/ merge-multiple: true - - name: Rehearse PyPI publish (dry-run — no upload) - # IMPORTANT: Do NOT change this pin back to a commit SHA — see the - # extended comment in the publish-python step below (PF-040). - uses: pypa/gh-action-pypi-publish@v1.14.2 - with: - packages-dir: python-dist/ - dry-run: true + # Mirrors the F4 completeness gate in publish-python: download-artifact + # exits 0 on zero matches, so an empty python-dist/ would make every check + # below vacuously green (PF-013). + - name: Assert all 8 Python distributions are present + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + ls -la python-dist/ + whl=(python-dist/*.whl); sd=(python-dist/*.tar.gz) + [ "${#whl[@]}" -eq 7 ] || { echo "::error::expected 7 wheels, found ${#whl[@]}"; exit 1; } + [ "${#sd[@]}" -eq 1 ] || { echo "::error::expected 1 sdist, found ${#sd[@]}"; exit 1; } + echo "Python distributions complete: ${#whl[@]} wheels + ${#sd[@]} sdist" + - name: "Gate 1/4: publish pin is a release tag, not a SHA (PF-040)" + shell: bash + run: | + set -euo pipefail + # A commit SHA — or an annotated-tag-object SHA — is a well-formed, + # GPG-verifiable git ref that GHCR has no image for. v0.4.1 shipped + # exactly that and publish-python died on "manifest unknown" AFTER + # crates.io and 15 npm packages had published irreversibly. + is_release_tag() { [[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; } + + # Positive control first (PF-013): the v0.4.1 pin must be REJECTED, or + # this gate is a no-op that would accept the exact value that broke a + # release. a892a5a6 is the annotated tag OBJECT of v1.14.2. + BAD_PIN=a892a5a61159132606e93a2fa6f4358831b04d26 + if is_release_tag "${BAD_PIN}"; then + echo "::error::positive control FAILED — the pin-shape gate accepted the v0.4.1 annotated-tag-object SHA ${BAD_PIN}. The gate is broken, not the pin (PF-013)." + exit 1 + fi + echo "positive control OK: ${BAD_PIN} rejected by the pin-shape gate" + + if ! is_release_tag "${PIN_REF}"; then + echo "::error::pypa/gh-action-pypi-publish pin '${PIN_REF}' is not a vX.Y.Z release tag. GHCR publishes images for release tags only, so a SHA pin cannot resolve to an image under any circumstance (PF-040)." + exit 1 + fi + echo "pin shape OK: ${PIN_REF}" + - name: "Gate 2/4: GHCR holds a manifest for the pinned ref (PF-040)" + shell: bash + run: | + set -euo pipefail + # The action generates a Docker trampoline against + # ghcr.io/pypa/gh-action-pypi-publish:. This asks + # GHCR the same question the runner will ask at publish time — the + # only question that matters. "The ref resolves in git" is necessary + # and never sufficient (PF-040). + TOKEN=$(curl -sS --fail --max-time 30 \ + 'https://ghcr.io/token?scope=repository:pypa/gh-action-pypi-publish:pull' \ + | jq -r '.token') + [ -n "${TOKEN}" ] && [ "${TOKEN}" != "null" ] \ + || { echo "::error::could not obtain an anonymous GHCR pull token"; exit 1; } + + # No --fail here: a 404 is a RESULT to report, not a curl error to + # abort on. (--fail would exit non-zero under set -e and the message + # below — the one naming the actual problem — would never print.) + # One Accept header listing every media type: some registries honour + # only the first Accept line when several are sent separately, and a + # manifest served as the wrong type would answer 404 for an image that + # exists — a false failure in a release gate. + ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json' + manifest_status() { + curl -sS -o /dev/null -w '%{http_code}' --max-time 30 \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Accept: ${ACCEPT}" \ + "https://ghcr.io/v2/pypa/gh-action-pypi-publish/manifests/$1" + } + + # Positive control (PF-013): a ref that cannot exist must NOT be 200. + CONTROL=$(manifest_status "${BOGUS_REF}") + if [ "${CONTROL}" = "200" ]; then + echo "::error::positive control FAILED — GHCR returned 200 for the bogus ref ${BOGUS_REF}. This probe cannot tell a present image from an absent one (PF-013)." + exit 1 + fi + echo "positive control OK: ${BOGUS_REF} → HTTP ${CONTROL} (not 200)" + + STATUS=$(manifest_status "${PIN_REF}") + if [ "${STATUS}" != "200" ]; then + echo "::error::GHCR has no image for ${PUBLISH_IMAGE}:${PIN_REF} (HTTP ${STATUS}). publish-python would fail with 'manifest unknown' AFTER crates.io and npm have published irreversibly (PF-040). Update PIN_REF and the uses: pins in publish-python and publish-testpypi together." + exit 1 + fi + echo "GHCR manifest OK: ${PUBLISH_IMAGE}:${PIN_REF} → HTTP ${STATUS}" + - name: "Gate 3/4: docker pull the image the publish step will run (PF-040)" + shell: bash + run: | + set -euo pipefail + # Positive control (PF-013): pulling a ref that cannot exist must fail. + if docker pull --quiet "${PUBLISH_IMAGE}:${BOGUS_REF}" >/dev/null 2>&1; then + echo "::error::positive control FAILED — docker pull succeeded for the bogus ref ${BOGUS_REF}. The pull gate proves nothing (PF-013)." + exit 1 + fi + echo "positive control OK: docker pull ${PUBLISH_IMAGE}:${BOGUS_REF} failed as expected" + + docker pull --quiet "${PUBLISH_IMAGE}:${PIN_REF}" + # Go template, not a GitHub expression: Actions only interpolates ${{ }}, + # so a bare {{ }} passes through untouched. `json` rather than + # `index .RepoDigests 0` because index on an empty slice is a template + # error, and a diagnostic print must not be able to fail the gate. + docker image inspect "${PUBLISH_IMAGE}:${PIN_REF}" \ + --format 'pulled digests: {{json .RepoDigests}}' + - name: "Gate 4/4: twine check the distributions (no upload, no network)" + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + # twine runs out of the very image publish-python will run, with the + # network switched off: this step physically cannot reach pypi.org. + # --entrypoint twine bypasses the image's /app/twine-upload.sh + # entrypoint, which is the part that uploads. + twine_check() { + local dir="$1"; shift + docker run --rm --network none \ + -v "${dir}:/dist:ro" \ + --entrypoint twine "${PUBLISH_IMAGE}:${PIN_REF}" check "$@" + } + + # Positive control (PF-013): a corrupt wheel must be REJECTED. A green + # `twine check` over the real distributions says nothing until the + # command is observed failing on something. + CONTROL_DIR="${RUNNER_TEMP}/twine-positive-control" + mkdir -p "${CONTROL_DIR}" + printf 'not a wheel' > "${CONTROL_DIR}/mds_control-0.0.0-py3-none-any.whl" + if twine_check "${CONTROL_DIR}" /dist/mds_control-0.0.0-py3-none-any.whl; then + echo "::error::positive control FAILED — twine check passed a corrupt wheel. The check proves nothing (PF-013)." + exit 1 + fi + echo "positive control OK: twine check rejected the corrupt wheel" + + dists=(python-dist/*) + [ "${#dists[@]}" -gt 0 ] \ + || { echo "::error::no distributions to check — an empty check is not a pass (PF-013)"; exit 1; } + # Map host paths to their in-container paths; the container gets no + # shell, so the glob is expanded here rather than inside it. + args=(); for f in "${dists[@]}"; do args+=("/dist/$(basename "${f}")"); done + twine_check "${GITHUB_WORKSPACE}/python-dist" "${args[@]}" + echo "twine check OK for ${#dists[@]} distribution(s) — nothing was uploaded" # --------------------------------------------------------------------------- # Opt-in TestPyPI leg — only runs on workflow_dispatch with testpypi: true. @@ -1025,10 +1185,13 @@ jobs: # The v0.4.1 release used `@a892a5a...`, which is the annotated *tag # object* SHA for v1.14.2 (not the commit SHA). No GHCR image exists for # it, so the publish-python job failed with "manifest unknown". - # Using the release tag name is unambiguous. The version-gate GHCR probe - # verifies the pin resolves before any irreversible publish starts. - # When bumping this pin: update PIN_REF in the GHCR probe step above - # AND the uses: pin in rehearse-publish-python and publish-testpypi. + # Using the release tag name is unambiguous. rehearse-publish-python + # proves this exact ref resolves to a pullable GHCR image (and that twine + # accepts the distributions) before publish-crates makes crates.io + # irreversible — publish-crates needs that job. + # When bumping this pin: update PIN_REF in rehearse-publish-python AND + # the uses: pin in publish-testpypi, together. Spec S16 fails the build + # if they drift. uses: pypa/gh-action-pypi-publish@v1.14.2 with: packages-dir: python-dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a79b9a7c..c406fa4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cargo dependency sweep: napi 3.9.0 → 3.12.2, napi-derive 3.5.6 → 3.6.3, napi-build 2.3.2 → 2.4.1 (napi-sys 3.3.0, napi-derive-backend 6.1.2), pyo3 0.29.0 → 0.29.2, clap 4.6.1 → 4.6.6, similar 3.1.1 → 3.2.0, wasm-bindgen 0.2.121 → 0.2.126 (js-sys 0.3.103, wasm-bindgen-futures 0.4.76, wasm-bindgen-test 0.3.76), serde 1.0.228 → 1.0.229, serde_json 1.0.150 → 1.0.151, thiserror 2.0.18 → 2.0.20, libc 0.2.186 → 0.2.189. Supersedes Dependabot #354 #360 #359 #358 #280 #251 #249 #246 #243. - npm dependency sweep: relaxed the three phantom floor pins to caret ranges — fast-uri 3.1.5 → ^3.1.6 (oldest release patching GHSA-5jgf-p345-68v8, GHSA-fph4-wmhf-6fwf, GHSA-f65p-4m7j-42xc, GHSA-jqff-g426-hqxp), nanoid 3.3.18 → ^3.3.18, js-yaml 4.3.1 → ^4.3.1 (#336); @napi-rs/cli ^3.0.0 → ^3.8.6 (lock 3.7.0 → 3.8.6); vite lock 8.1.5 → 8.2.2; Dependabot `ignore` rules for semver-major bumps of the three phantom pins. Supersedes Dependabot #315 #332 #346 #362 #355 #357 #279. - GitHub Actions sweep: actions/checkout v6 → v7 (16 call sites: 9 ci.yml + 7 release.yml), actions/setup-node v6 → v7 (6 sites), actions/setup-python v5 → v7 (5 sites, ci.yml only; action runtime node20 → node24), PyO3/maturin-action pin normalized from the v1.51.0 annotated-tag object (`3e2bdf6`) to the commit it points to (`e83996d1`), same version (PF-040); Dependabot `ignore` for typescript semver-major version updates pending the TS 7 migration (#364). Supersedes Dependabot #111, #189, #241, #356; replaces #169. -- Release-surface PR gate: `release.yml` now triggers on `pull_request` events touching `.github/workflows/release.yml`, `.github/actions/**`, `crates/mds-napi/**`, `crates/mds-python/**`, and `scripts/verify-napi-names.mjs`; adds `rehearse-publish-python` job (OIDC dry-run, unguarded per PF-039) and `publish-testpypi` opt-in leg; GHCR manifest probe in `version-gate` for the `pypa/gh-action-pypi-publish` pin (PF-040, #350); CI-history step guard is now step-level (ADR-013 amendment); `verify-pr-checks.mjs` requires `Rehearse PyPI publish (no upload)` on release-surface PRs (#342, #350). +- Release-surface PR gate: `release.yml` now triggers on `pull_request` events touching `.github/workflows/release.yml`, `.github/actions/**`, `crates/mds-napi/**`, `crates/mds-python/**`, and `scripts/verify-napi-names.mjs`; adds an unguarded `rehearse-publish-python` job (PF-039) that rehearses `publish-python` without uploading — pin shape, GHCR manifest, `docker pull` and `twine check` run from the pinned image with `--network none`, each with a positive control (PF-013, PF-040) — plus a `publish-testpypi` opt-in leg; the rehearsal never invokes `pypa/gh-action-pypi-publish` (the action has no dry-run mode: an unrecognised input is ignored and it uploads for real) and is denied `id-token`, both pinned by specs S14/S15; credential, OIDC and CI-history gates in `version-gate` are skipped at step level on `pull_request` so fork and Dependabot PRs reach `success` (ADR-013 amendment); `verify-pr-checks.mjs` requires `Version gate`, `Stage + verify platform packages` and `Rehearse PyPI publish (no upload)` on release-surface PRs and fails closed when the changed-file list cannot be read (#342, #350). ## [0.4.2] — 2026-09-03 diff --git a/RELEASING.md b/RELEASING.md index 67b9ce05..d88f942d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -57,7 +57,9 @@ These are **not** automated and must be done before the first release: record causes version-gate to fail, aborting the release before any crates.io or npm publish runs. The minted token expires unused — the probe is free and safe. This step is NOT tag-guarded so it runs in the `workflow_dispatch` dry run too, - exercising the PyPI trust chain before the real tag push (PF-039). + exercising the PyPI trust chain before the real tag push (PF-039). It is + skipped on `pull_request` events, which receive no `id-token` on forks and + cannot reach a publish in any case. 6. **Configure TestPyPI trusted publisher** (optional, needed for `testpypi: true` dispatch runs) at [test.pypi.org/manage/account/publishing](https://test.pypi.org/manage/account/publishing/): @@ -138,18 +140,23 @@ gh workflow run release.yml # workflow_dispatch — builds the 7-target # Python wheel matrix, stages packages, # runs the A3 name<->loader gate and the # Python readelf linkage gate, uploads - # artifacts. Rehearses the PyPI OIDC - # exchange. Publishes NOTHING. + # artifacts. Rehearses the publish-python + # step. Publishes NOTHING. ``` The dry-run workflow runs `version-gate` in full, which now includes the **credential probe** (security-08): it calls `npm whoami` against the live registry to verify the `NPM_TOKEN` is valid, guards `CARGO_REGISTRY_TOKEN` -for non-empty, probes the PyPI trusted publisher via the OIDC mint-token -exchange, and probes the GHCR manifest for the `pypa/gh-action-pypi-publish` -pin (PF-040). A revoked token, absent secret, misconfigured trusted publisher, -or broken action pin therefore fails the dry run — all before any irreversible -crates.io release. +for non-empty, and probes the PyPI trusted publisher via the OIDC mint-token +exchange. A revoked token, absent secret, or misconfigured trusted publisher +therefore fails the dry run — all before any irreversible crates.io release. + +Both probes are **skipped on `pull_request` events** (step-level, so +`version-gate` itself still runs and succeeds — ADR-013 amendment). Fork and +Dependabot PRs receive no repository secrets and no `id-token: write`, so the +probes cannot pass there, and no PR run can reach a registry write. They run +and fail closed on tag push and `workflow_dispatch`, which are the only events +that publish. **Note:** `npm whoami` verifies authentication, not publish rights to the `@mdscript` scope. A read-only or wrongly-scoped token passes the probe but @@ -159,16 +166,40 @@ The dry run also exercises the **CI-history gate** (PF-017), asserting a completed+success `CI` run for the dispatched ref's HEAD. Dispatch it only after that ref's CI has finished, or the gate fails closed on a still-running run. -The dry run also runs the new **`rehearse-publish-python` job** — a `dry-run: true` -publish that exercises the OIDC exchange end-to-end without uploading any wheels. -A misconfigured or expired trusted publisher fails here, before `publish-crates` -starts (PF-039: the rehearsal job is intentionally unguarded so it runs on dispatch -and PRs, not just tag pushes). +The dry run also runs the **`rehearse-publish-python` job**, which rehearses +everything about `publish-python` except the one irreversible act. It runs four +gates, each with a positive control (PF-013 — a gate never observed rejecting +anything is not evidence): + +1. **Pin shape** — the `pypa/gh-action-pypi-publish` pin must be a `vX.Y.Z` + release tag. Control: the v0.4.1 annotated-tag-object SHA must be rejected. +2. **GHCR manifest** — GHCR must hold an image for that exact ref. Control: a ref + that cannot exist must not return HTTP 200. +3. **`docker pull`** — the image the runner actually fetches must pull. Control: + pulling the impossible ref must fail. ("The ref resolves in git" is necessary + and never sufficient — PF-040.) +4. **`twine check`** — twine runs out of that image with `--network none` and + `--entrypoint twine`, so the step physically cannot reach pypi.org. Control: + a deliberately corrupt wheel must be rejected. + +> The job **must never** `uses: pypa/gh-action-pypi-publish`. The action has no +> dry-run/no-upload mode: an unrecognised `dry-run:` input is warned about and +> ignored, and the action then uploads for real. A `dry-run: true` rehearsal +> shipped briefly and attempted a live pypi.org upload from a pull request +> (run 34060146952); it failed only because that version was already published. +> Spec S14 in `scripts/__test__/release-auth-probe.spec.mjs` now pins the +> invocation to `publish-python` and `publish-testpypi` only, and the rehearsal +> is denied `id-token` so it holds no credential to upload with. + +`publish-crates` needs this job, so a broken pin aborts the release before the +irreversible crates.io write. It is intentionally unguarded, so it runs on +`pull_request` and `workflow_dispatch`, not just tag pushes (PF-039). Five jobs are expected-skipped on a standard `workflow_dispatch` dry run and are listed in `TIER_B_EXPECTED_SKIPPED` in `scripts/verify-pr-checks.mjs`: `Publish to crates.io`, `Publish to npm`, `Publish to PyPI`, `GitHub Release`, -and `Publish to TestPyPI (rehearsal)`. +and `Publish to TestPyPI (rehearsal)`. The same five are skipped on a +release-surface PR run. Confirm the **A3 name-gate** step (`scripts/verify-napi-names.mjs`) passes in that run. **This is a hard checkpoint** — if the generated platform package names or @@ -176,15 +207,26 @@ their `.node` filenames drift from the hand-written `crates/mds-napi/index.js` loader, the published universal package will fail to load the native binary at runtime on the affected platform. Do not proceed past a failing gate. -### PyPI publish rehearsal +### Release-surface PRs -Release-surface PRs (those touching `.github/workflows/release.yml`, +Release-surface PRs — those touching `.github/workflows/release.yml`, `.github/actions/**`, `crates/mds-napi/**`, `crates/mds-python/**`, or -`scripts/verify-napi-names.mjs`) also trigger the workflow via the -`pull_request` event. On such PRs, `verify-pr-checks.mjs` requires three -additional check-runs: `Version gate`, `Stage + verify platform packages`, and -`Rehearse PyPI publish (no upload)`. All other publish jobs are skipped and -their skipped conclusions are tolerated by the verifier. +`scripts/verify-napi-names.mjs` — also trigger the workflow via the +`pull_request` event, so a Dependabot bump to an action reachable only from a +tag-guarded job is exercised on the PR instead of first running on a tag push +after crates.io has published (PF-039). + +On such PRs, `verify-pr-checks.mjs` requires three additional check-runs: +`Version gate`, `Stage + verify platform packages`, and `Rehearse PyPI publish +(no upload)`. All other publish jobs are skipped, and their skipped conclusions +are tolerated by the verifier. + +That path list lives in **two** places that must stay identical: the +`on.pull_request.paths` filter in `release.yml` and `RELEASE_SURFACE` in +`scripts/verify-pr-checks.mjs`. Spec S10 compares them as sets — a filter the +verifier does not know about would let a release-surface PR pass as a silent +no-run (ADR-013 amendment). The verifier also fails closed (exit 2) if it +cannot enumerate the PR's changed files at all. ## Release @@ -222,17 +264,18 @@ The release is driven by pushing a `vX.Y.Z` tag. This is how all versions have s ### What happens after tagging The `release.yml` workflow runs, in order: - 1. **version-gate** — synchronized-version check, credential probe, GHCR pin - probe, PyPI OIDC probe, and CI-history gate (fails fast). + 1. **version-gate** — synchronized-version check, credential probe, PyPI OIDC + probe, source-hygiene gate, and CI-history gate (fails fast). 2. **build-napi** (parallel with build-python) — cross-compiles the addon for all 7 targets. 3. **build-python** (parallel with build-napi) — builds `cp311-abi3` wheels for 7 platforms + sdist, runs the readelf linkage gate on Linux legs. 4. **stage-and-verify-napi** — `napi create-npm-dirs` + `artifacts`, copies LICENSE into each platform dir, runs the **A3 name-gate**. - 5. **rehearse-publish-python** — `dry-run: true` publish to PyPI; exercises the - OIDC exchange end-to-end without uploading any wheels. publish-crates blocks - on this so a broken trusted publisher aborts before crates.io (irreversible). + 5. **rehearse-publish-python** — pin shape, GHCR manifest, `docker pull` and + `twine check` (each with a positive control); uploads nothing and holds no + OIDC token. publish-crates blocks on this so a broken action pin aborts + before crates.io (irreversible). 6. **publish-crates** — blocked until `stage-and-verify-napi`, `build-python`, AND `rehearse-publish-python` succeed. `cargo publish` `mds-core`, polls the crates.io index for up to 5 min (bounded, max 20 × 15 s), then `mds-cli`. @@ -242,6 +285,9 @@ The `release.yml` workflow runs, in order: 8. **github-release** — `gh release create` with generated notes; runs only after all three publish jobs succeed. + `publish-testpypi` never runs on a tag: it is guarded by `inputs.testpypi`, + which only a `workflow_dispatch` can set. On a tag push it reports `skipped`. + ## Post-release - Verify each package on its registry (crates.io, npmjs.com) and that npm shows diff --git a/scripts/__test__/release-auth-probe.spec.mjs b/scripts/__test__/release-auth-probe.spec.mjs index d15ef097..ec72aeef 100644 --- a/scripts/__test__/release-auth-probe.spec.mjs +++ b/scripts/__test__/release-auth-probe.spec.mjs @@ -25,6 +25,7 @@ import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { RELEASE_SURFACE, + RELEASE_SURFACE_CONTEXTS, TIER_B_EXPECTED_SKIPPED, } from '../verify-pr-checks.mjs'; @@ -262,6 +263,48 @@ function extractJobIf(jobSection) { return null; } +/** + * Drop whole-line YAML comments. + * + * Checks that ask "does this job invoke X?" must read executable YAML only: a + * comment naming `uses: pypa/gh-action-pypi-publish` (as the rehearsal's own + * warning-not-to does) is documentation, not an invocation, and a guard that + * cannot tell them apart fires on the text that exists to prevent the defect. + */ +function stripCommentLines(text) { + return text.split('\n').filter(l => !/^\s*#/.test(l)).join('\n'); +} + +/** + * Extract the `on.pull_request.paths:` list as an array of path patterns. + * Returns null when the trigger or its paths filter is absent. + * + * Indentation contract inside the `on:` block: + * 2-space: event names (push, pull_request, workflow_dispatch) + * 4-space: event fields (paths, tags, inputs) + * 6-space: list items (` - ''`) + */ +function extractPullRequestPaths(source) { + const onBlock = extractOnBlock(source); + if (onBlock === null) return null; + const lines = onBlock.split('\n'); + const prIdx = lines.findIndex(l => /^ pull_request:\s*$/.test(l)); + if (prIdx === -1) return null; + let pathsIdx = -1; + for (let i = prIdx + 1; i < lines.length; i++) { + if (/^ [a-z_]+:/.test(lines[i])) break; // next event — paths not found + if (/^ paths:\s*$/.test(lines[i])) { pathsIdx = i; break; } + } + if (pathsIdx === -1) return null; + const out = []; + for (let i = pathsIdx + 1; i < lines.length; i++) { + const m = /^ - ['"]?([^'"]+)['"]?\s*$/.exec(lines[i]); + if (!m) break; + out.push(m[1]); + } + return out; +} + // --------------------------------------------------------------------------- // B1 structural invariants (S5-S13) // --------------------------------------------------------------------------- @@ -321,19 +364,24 @@ describe('B1: release-surface PR gate and rehearsal jobs', () => { }); // ------------------------------------------------------------------------- - // S8: version-gate must contain a GHCR manifest probe for the + // S8: the rehearsal must contain a GHCR manifest probe for the // pypa/gh-action-pypi-publish pin (PF-040, #350 option 3). // An absent or stale pin (SHA instead of tag name) silently breaks the // publish-python job at runtime with "manifest unknown". + // + // The probe lives in rehearse-publish-python, not version-gate: publish-crates + // needs the rehearsal, so a bad pin still aborts the release before the + // irreversible crates.io write, and keeping one copy means there is one place + // to bump when the pin moves (a second copy is a place to forget). // ------------------------------------------------------------------------- - test('S8: version-gate contains GHCR manifest probe for pypa/gh-action-pypi-publish', () => { - const section = extractJobSection(yml, 'version-gate'); - assert.ok(section !== null, 'version-gate must exist'); + test('S8: rehearse-publish-python contains GHCR manifest probe for pypa/gh-action-pypi-publish', () => { + const section = extractJobSection(yml, 'rehearse-publish-python'); + assert.ok(section !== null, 'rehearse-publish-python must exist'); assert.ok( section.includes('ghcr.io/v2/pypa/gh-action-pypi-publish/manifests'), - 'version-gate must probe the GHCR manifest for the pypa/gh-action-pypi-publish ' + - 'pin (#350 option 3, PF-040); a missing or SHA-pinned image causes publish-python ' + - 'to fail at runtime with "manifest unknown"; ' + + 'rehearse-publish-python must probe the GHCR manifest for the ' + + 'pypa/gh-action-pypi-publish pin (#350 option 3, PF-040); a missing or SHA-pinned ' + + 'image causes publish-python to fail at runtime with "manifest unknown"; ' + `got section (first 600 chars):\n${section.slice(0, 600)}`, ); }); @@ -374,7 +422,7 @@ describe('B1: release-surface PR gate and rehearsal jobs', () => { // S10: The on: block must include a pull_request trigger with at least one // path from RELEASE_SURFACE (ensuring release-surface PRs exercise the gate). // ------------------------------------------------------------------------- - test('S10: on: block includes pull_request trigger with RELEASE_SURFACE paths', () => { + test('S10: on.pull_request.paths equals RELEASE_SURFACE exactly (as a set)', () => { const onBlock = extractOnBlock(yml); assert.ok(onBlock !== null, 'release.yml must have an on: block'); assert.ok( @@ -382,16 +430,45 @@ describe('B1: release-surface PR gate and rehearsal jobs', () => { 'release.yml must have a pull_request: trigger so release-surface PRs are validated; ' + `got on: block:\n${onBlock}`, ); - // At least one RELEASE_SURFACE entry must appear in the on: block paths list. - const hasPath = RELEASE_SURFACE.some(entry => { - const base = entry.endsWith('/**') ? entry.slice(0, -3) : entry; - return onBlock.includes(base); - }); + + const paths = extractPullRequestPaths(yml); assert.ok( - hasPath, - 'pull_request trigger paths must include at least one entry from RELEASE_SURFACE; ' + - `RELEASE_SURFACE = [\n ${RELEASE_SURFACE.join(',\n ')}\n]; ` + - `got on: block:\n${onBlock}`, + Array.isArray(paths) && paths.length > 0, + 'on.pull_request.paths must be a non-empty list — an unfiltered trigger would run the ' + + 'whole release matrix on every PR, and an unparseable one would make this test vacuous ' + + `(PF-013); got on: block:\n${onBlock}`, + ); + + // Positive control (PF-013): the extractor must actually FIND a planted entry. + // Without this, a regex that silently returns [] would satisfy nothing and the + // set comparison below would be comparing two empty sets. + const control = extractPullRequestPaths([ + 'on:', + ' pull_request:', + ' paths:', + " - 'planted/control/**'", + ' workflow_dispatch:', + 'permissions:', + ].join('\n')); + assert.deepEqual( + control, ['planted/control/**'], + 'positive control: extractPullRequestPaths must parse a planted paths list; ' + + `got ${JSON.stringify(control)}`, + ); + + // The two lists must be EQUAL as sets. This is the invariant the D-PR6 header + // comment in verify-pr-checks.mjs asserts: RELEASE_SURFACE is the verifier's + // model of what triggers this workflow, and a filter the verifier does not + // know about is a path whose PR silently gets no release run (ADR-013 + // amendment 2026-09-06 — a mis-specified paths filter must not pass as a + // silent no-run). + assert.deepEqual( + [...paths].sort(), [...RELEASE_SURFACE].sort(), + 'on.pull_request.paths and RELEASE_SURFACE (verify-pr-checks.mjs) must be the same set. ' + + 'A path in the workflow but not in RELEASE_SURFACE runs the release matrix without the ' + + 'verifier requiring it; a path in RELEASE_SURFACE but not in the workflow makes the ' + + 'verifier demand release check-runs that can never appear, hard-failing the PR. ' + + `workflow paths = ${JSON.stringify(paths)}; RELEASE_SURFACE = ${JSON.stringify(RELEASE_SURFACE)}`, ); }); @@ -435,11 +512,29 @@ describe('B1: release-surface PR gate and rehearsal jobs', () => { assert.ok(section !== null, 'publish-testpypi must exist (see S11)'); // Extract the job name: field (4-space indent). - let jobName = null; - for (const line of section.split('\n')) { - const m = /^ name:\s+(.+)$/.exec(line); - if (m) { jobName = m[1].trim(); break; } - } + const jobNameOf = (text) => { + for (const line of text.split('\n')) { + const m = /^ name:\s+(.+)$/.exec(line); + if (m) return m[1].trim(); + } + return null; + }; + + // Positive control (PF-013): the pair (extractor, membership check) must be + // able to FAIL. A planted section whose name is not in the allowlist has to + // be flagged — otherwise `TIER_B_EXPECTED_SKIPPED.has(jobName)` could be + // passing on a name nobody set, or on an extractor that never returns. + const plantedName = jobNameOf([ + ' publish-testpypi:', + ' name: Publish to Somewhere Nobody Allowed', + ' runs-on: ubuntu-latest', + ].join('\n')); + assert.equal(plantedName, 'Publish to Somewhere Nobody Allowed', + 'positive control: the name: extractor must read a planted name'); + assert.ok(!TIER_B_EXPECTED_SKIPPED.has(plantedName), + 'positive control: an unlisted job name must NOT be treated as an allowed skip'); + + const jobName = jobNameOf(section); assert.ok( jobName !== null, `publish-testpypi must have a name: field; got section:\n${section}`, @@ -453,4 +548,234 @@ describe('B1: release-surface PR gate and rehearsal jobs', () => { ); }); + // ------------------------------------------------------------------------- + // S14: pypa/gh-action-pypi-publish must be invoked ONLY by the two jobs that + // genuinely publish. This is a drift guard for a defect that actually shipped. + // + // Run 34060146952 (a pull_request run of this workflow) executed + // `uses: pypa/gh-action-pypi-publish@v1.14.2` with `dry-run: true` inside + // rehearse-publish-python. v1.14.2 has NO dry-run input: the runner logged + // "Unexpected input(s) 'dry-run'", the action ignored it, and then performed a + // REAL upload to https://upload.pypi.org/legacy/ from a pull request. It failed + // only because the workspace version (0.4.2) was already on PyPI — on a + // version-bump PR (which touches crates/mds-python/Cargo.toml and therefore + // matches the release-surface paths filter) the upload would have SUCCEEDED, + // publishing an unreleased version from an unmerged branch. + // + // There is no no-upload mode to configure. The rehearsal must reproduce what + // the action does — pull its GHCR image and run twine out of it — never call it. + // ------------------------------------------------------------------------- + test('S14: pypa/gh-action-pypi-publish is invoked ONLY by publish-python and publish-testpypi', () => { + const USES = 'uses: pypa/gh-action-pypi-publish'; + + // Positive control (PF-013): `!section.includes(USES)` is satisfied by ANY + // string, including the empty one a broken extractor would return. Prove the + // check flags a section that does carry the invocation before trusting it on + // the real ones. + const plantedRehearsal = [ + ' rehearse-publish-python:', + ' name: Rehearse PyPI publish (no upload)', + ' steps:', + ' - uses: pypa/gh-action-pypi-publish@v1.14.2', + ' with:', + ' dry-run: true', + ].join('\n'); + assert.ok( + plantedRehearsal.includes(USES), + 'positive control: a section carrying the invocation must be detected by this check', + ); + + assert.ok( + !stripCommentLines(plantedRehearsal.replace(' - uses:', ' # - uses:')).includes(USES), + 'positive control: a commented-out invocation must NOT count as an invocation', + ); + + for (const jobId of ['rehearse-publish-python', 'version-gate']) { + const section = stripCommentLines(extractJobSection(yml, jobId) ?? ''); + assert.ok(section !== '', `${jobId} must exist`); + assert.ok( + !section.includes(USES), + `${jobId} must NOT invoke pypa/gh-action-pypi-publish. The action has no ` + + `dry-run/no-upload mode: an unrecognised input is warned about and ignored, and ` + + `the action then uploads for real (run 34060146952 did exactly that from a ` + + `pull_request). Reproduce the action instead of calling it — GHCR probe, ` + + `docker pull, twine check (PF-039, PF-040);\ngot section:\n${section}`, + ); + } + + const invocations = stripCommentLines(yml).split('\n').filter(l => l.includes(USES)); + assert.equal( + invocations.length, 2, + `release.yml must invoke pypa/gh-action-pypi-publish exactly twice — once in ` + + `publish-python (PyPI) and once in publish-testpypi (TestPyPI). Every other ` + + `invocation is an upload nobody asked for; found ${invocations.length}: ` + + `${JSON.stringify(invocations.map(l => l.trim()))}`, + ); + for (const jobId of ['publish-python', 'publish-testpypi']) { + const section = extractJobSection(yml, jobId); + assert.ok(section !== null, `${jobId} must exist`); + assert.ok( + section.includes(USES), + `${jobId} is one of the two jobs that must invoke pypa/gh-action-pypi-publish; ` + + `got section:\n${section}`, + ); + } + }); + + // ------------------------------------------------------------------------- + // S15: the rehearsal must not hold id-token: write. + // + // Defence in depth behind S14: without id-token the job cannot mint a PyPI + // trusted-publishing token, so even a re-introduced upload step has no + // credential to upload with. `contents: read` is the whole permission set. + // ------------------------------------------------------------------------- + test('S15: rehearse-publish-python has contents: read and NO id-token permission', () => { + const raw = extractJobSection(yml, 'rehearse-publish-python'); + assert.ok(raw !== null, 'rehearse-publish-python must exist (see S5)'); + const section = stripCommentLines(raw); + assert.ok( + section.includes('contents: read'), + `rehearse-publish-python must declare permissions: contents: read; got:\n${section}`, + ); + assert.ok( + !section.includes('id-token'), + 'rehearse-publish-python must NOT be granted id-token — a job that cannot mint a ' + + 'PyPI trusted-publishing token cannot upload even if an upload step is ' + + 're-introduced (defence in depth behind S14); ' + + `got section:\n${section}`, + ); + }); + + // ------------------------------------------------------------------------- + // S16: the pin the rehearsal validates must be the pin the publish jobs use. + // + // The rehearsal proves an image exists for PIN_REF. If PIN_REF and the + // `uses: ...@` pins drift, the rehearsal proves the wrong image and the + // release fails on the pin it never checked — the v0.4.1 shape (PF-040). + // ------------------------------------------------------------------------- + test('S16: rehearse-publish-python PIN_REF equals every pypa/gh-action-pypi-publish pin', () => { + const raw = extractJobSection(yml, 'rehearse-publish-python'); + assert.ok(raw !== null, 'rehearse-publish-python must exist (see S5)'); + const section = stripCommentLines(raw); + const m = /^\s+PIN_REF:\s*(\S+)\s*$/m.exec(section); + assert.ok( + m !== null, + 'rehearse-publish-python must declare a PIN_REF env var naming the pin under test; ' + + `got section:\n${section}`, + ); + const pin = m[1]; + + const refs = [...stripCommentLines(yml) + .matchAll(/uses:\s*pypa\/gh-action-pypi-publish@(\S+)/g)].map(x => x[1]); + // Non-vacuity (PF-013): an empty ref list would make the loop below pass + // without comparing anything. + assert.ok( + refs.length > 0, + 'release.yml must invoke pypa/gh-action-pypi-publish somewhere (see S14); found none', + ); + for (const ref of refs) { + assert.equal( + ref, pin, + `pypa/gh-action-pypi-publish is pinned to "${ref}" but the rehearsal validates ` + + `PIN_REF="${pin}". The rehearsal would prove an image that the publish job never ` + + `pulls (PF-040). Bump both together.`, + ); + } + }); + + // ------------------------------------------------------------------------- + // S17: the rehearsal's four gates, each with a positive control. + // + // PF-013: a gate that has never been observed rejecting anything is not + // evidence. Each gate below runs a known-bad input first and fails if that + // input is ACCEPTED. + // ------------------------------------------------------------------------- + test('S17: rehearsal gates the pin shape, GHCR manifest, docker pull and twine — with positive controls', () => { + const section = extractJobSection(yml, 'rehearse-publish-python'); + assert.ok(section !== null, 'rehearse-publish-python must exist (see S5)'); + + const gates = [ + ['ghcr.io/v2/pypa/gh-action-pypi-publish/manifests', + 'GHCR manifest probe — asks GHCR the same question the runner asks at publish time'], + ['docker pull', + 'docker pull — proves the artifact the RUNTIME fetches, not just that the git ref resolves (PF-040)'], + ['--entrypoint twine', + 'twine check must run out of the publish image, bypassing its upload entrypoint'], + ['--network none', + 'the twine check must run with the network switched off so the rehearsal physically cannot reach pypi.org'], + ]; + for (const [needle, why] of gates) { + assert.ok( + section.includes(needle), + `rehearse-publish-python must contain "${needle}": ${why};\ngot section:\n${section}`, + ); + } + + // Each of the four gates must carry a positive control (PF-013). + const controlLines = section.split('\n').filter(l => l.includes('positive control')); + assert.ok( + controlLines.length >= gates.length, + `each of the ${gates.length} rehearsal gates needs a positive control (PF-013: a gate ` + + `never observed rejecting anything is not evidence); found ${controlLines.length} ` + + `line(s) mentioning one`, + ); + + // The controls must be concrete known-bad inputs, not prose. + assert.ok( + section.includes('a892a5a61159132606e93a2fa6f4358831b04d26'), + 'the pin-shape gate must be exercised against the v0.4.1 annotated-tag-object SHA ' + + 'that actually broke a release (PF-040), so the gate is proven to reject it', + ); + assert.ok( + section.includes('BOGUS_REF'), + 'the GHCR and docker-pull gates must be exercised against a ref that cannot exist, ' + + 'so a probe that returns 200 for everything is caught (PF-013)', + ); + }); + + // ------------------------------------------------------------------------- + // S18: every name the verifier REQUIRES on a release-surface PR must be a + // real job display-name in release.yml. + // + // RELEASE_SURFACE_CONTEXTS is Tier A semantics applied to release check-runs: + // absence is FAIL. So a job renamed in the workflow without the verifier being + // updated makes the verifier demand a check-run that can never appear, and the + // mandatory pre-merge gate hard-fails every release-surface PR — whose natural + // workaround is bypassing the gate, the PF-017 shape ADR-013's 2026-09-06 + // amendment warns about. Same three-place accounting, third place pinned. + // ------------------------------------------------------------------------- + test('S18: every RELEASE_SURFACE_CONTEXTS name is a real job name: in release.yml', () => { + const jobNames = new Set( + findAllJobIds(yml) + .map(id => { + const section = stripCommentLines(extractJobSection(yml, id) ?? ''); + const m = /^ name:\s+(.+)$/m.exec(section); + return m ? m[1].trim() : null; + }) + .filter(n => n !== null), + ); + + // Non-vacuity (PF-013): an empty set would satisfy nothing and make every + // membership assertion below unreachable. + assert.ok( + jobNames.size > 0, + `release.yml must declare job name: fields; found none among jobs [${findAllJobIds(yml).join(', ')}]`, + ); + // Positive control: a name nobody declared must NOT be found. + assert.ok( + !jobNames.has('Rehearse PyPI publish (no upload) — renamed'), + 'positive control: an undeclared job name must not be reported as present', + ); + + for (const ctx of RELEASE_SURFACE_CONTEXTS) { + assert.ok( + jobNames.has(ctx), + `RELEASE_SURFACE_CONTEXTS lists "${ctx}" but no job in release.yml carries that ` + + `name:. The verifier would require a check-run that can never appear, hard-failing ` + + `every release-surface PR (ADR-013 amendment 2026-09-06, PF-017). ` + + `Declared job names: [${[...jobNames].join(' | ')}]`, + ); + } + }); + }); diff --git a/scripts/__test__/verify-pr-checks.spec.mjs b/scripts/__test__/verify-pr-checks.spec.mjs index dcdfeb75..f5e2311f 100644 --- a/scripts/__test__/verify-pr-checks.spec.mjs +++ b/scripts/__test__/verify-pr-checks.spec.mjs @@ -1724,10 +1724,13 @@ describe('D-PR6: release-surface presence check', () => { 'crates/mds-core/** must NOT match (not in RELEASE_SURFACE)'); }); - test('D-PR6g: changedFiles undefined → exit 0 with skip notice (changedFiles is optional)', () => { - // When main() cannot fetch the changed-files list (API error), it passes - // changedFiles=undefined. evaluateChecks must skip the D-PR6 check and - // include a notice so the operator knows the check was skipped. + test('D-PR6g: changedFiles undefined → exit 0 with skip notice (caller-path only, never an API failure)', () => { + // evaluateChecks is an exported pure function; a caller holding only a SHA + // cannot enumerate changed files, so `undefined` skips the check with a + // notice. main() NEVER reaches this path: fetchChangedFiles fails closed on + // an API error (see D-PR6h) rather than degrading to undefined, because a + // transient 500 must not turn a release-surface PR into one that needs no + // release check-runs (fail-open in a merge gate). const checkRuns = passingRunsWith([]); const result = evaluateChecks({ requiredContexts: REQUIRED, @@ -1745,6 +1748,134 @@ describe('D-PR6: release-surface presence check', () => { ); }); + // ------------------------------------------------------------------------- + // D-PR6h: the live path must FAIL CLOSED when the files endpoint errors. + // + // This is the fail-open that would otherwise sit under D-PR6: a 500 from + // /pulls/N/files would let a PR that touches release.yml pass the verifier + // without a single release check-run. Indeterminate is exit 2 — the same + // contract fetchCheckRuns, fetchStatuses and fetchRequiredContexts hold. + // ------------------------------------------------------------------------- + test('D-PR6h: PR-files API error → exit 2 (fail closed, never a silent skip)', () => { + // Route the files URL to an error BEFORE the generic /pulls/ route, which + // would otherwise answer it with PR metadata. + const runner = stubRunner([ + ['/files', { __error: true, httpStatus: 500, stderr: 'server error' }], + ['/pulls/', PR_OK], + ['/protection', PROTECTION_OK], + ['/check-runs', CHECKS_OK_WITH_HYGIENE], + ['/status', { statuses: [], total_count: 0 }], + ]); + assert.equal( + main(['1'], runner, OK_GH_VERSION), 2, + 'a files-endpoint error is indeterminate: it must exit 2, not skip the ' + + 'release-surface check and report PASS', + ); + }); + + test('D-PR6h: changed_files count mismatch → exit 2 (partial file list)', () => { + // The PR declares 3 changed files but the endpoint returns 1. Evaluating + // the release-surface question on a partial list could miss the one file + // that touches the surface (non-vacuity, avoids PF-013). + const runner = stubRunner([ + ['/files', [{ filename: 'README.md' }]], + ['/pulls/', { ...PR_OK, changed_files: 3 }], + ['/protection', PROTECTION_OK], + ['/check-runs', CHECKS_OK_WITH_HYGIENE], + ['/status', { statuses: [], total_count: 0 }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 2, + 'a partial changed-files list must exit 2'); + }); + + test('D-PR6h: live path requires the release runs when the files endpoint reports a surface file', () => { + // End-to-end through main(): the files endpoint (not a hand-built + // changedFiles array) drives the presence check. Without the release + // check-runs this PR must FAIL — proving fetchChangedFiles is wired into + // evaluateChecks and that D-PR6a-f are not testing a disconnected function. + const runner = stubRunner([ + ['/files', [{ filename: 'crates/mds-napi/src/lib.rs' }]], + ['/pulls/', { ...PR_OK, changed_files: 1 }], + ['/protection', PROTECTION_OK], + ['/check-runs', CHECKS_OK_WITH_HYGIENE], + ['/status', { statuses: [], total_count: 0 }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 1, + 'a release-surface file with no release check-runs must exit 1'); + }); + + test('D-PR6h: live path passes when the release runs are present for a surface file', () => { + // The complement of the case above: same PR, plus the three release runs. + // Without this pair, exit 1 could be coming from anywhere. + const withRelease = { + ...CHECKS_OK_WITH_HYGIENE, + check_runs: [ + ...CHECKS_OK_WITH_HYGIENE.check_runs, + ...RELEASE_SURFACE_CONTEXTS.map(name => ({ + name, status: 'completed', conclusion: 'success', + })), + ], + total_count: CHECKS_OK_WITH_HYGIENE.total_count + RELEASE_SURFACE_CONTEXTS.length, + }; + const runner = stubRunner([ + ['/files', [{ filename: 'crates/mds-napi/src/lib.rs' }]], + ['/pulls/', { ...PR_OK, changed_files: 1 }], + ['/protection', PROTECTION_OK], + ['/check-runs', withRelease], + ['/status', { statuses: [], total_count: 0 }], + ]); + assert.equal(main(['1'], runner, OK_GH_VERSION), 0, + 'a release-surface file WITH all release check-runs must exit 0'); + }); + + // ------------------------------------------------------------------------- + // D-PR6j: duplicate names resolve ALL-MUST-PASS, not newest-wins. + // + // Same contract as Tier A (see the checksByName comment in the production + // file): `filter=latest` de-duplicates within one check-suite, but two suites + // can publish the same name, so keeping only the last entry would let a green + // re-run mask a red sibling — a fail-open in a merge gate. + // ------------------------------------------------------------------------- + test('D-PR6j: a failed and a succeeded run sharing a release-surface name → exit 1 (all must pass)', () => { + const checkRuns = passingRunsWith([ + ...releaseSuccessRuns(), + // A second run under a name that already has a success above. + { name: 'Version gate', status: 'completed', conclusion: 'failure' }, + ]); + const result = evaluateChecks({ + requiredContexts: REQUIRED, + checkRuns, + statuses: [], + headSha: HEAD_113F472, + changedFiles: ['.github/workflows/release.yml'], + }); + assert.equal(result.exitCode, 1, + 'a later success must not mask an earlier failure under the same name; must exit 1'); + const allLines = result.lines.join('\n'); + assert.ok(allLines.includes('Version gate') && allLines.includes('failure'), + `must name the failing run and its conclusion; got:\n${allLines}`); + }); + + // ------------------------------------------------------------------------- + // D-PR6i: matchesReleaseSurface prefix and exact-match boundaries. + // A `dir/**` pattern must not match a sibling whose name merely starts with + // the directory name, and an exact pattern must not prefix-match. + // ------------------------------------------------------------------------- + test('D-PR6i: "/**" requires a trailing slash and exact patterns do not prefix-match', () => { + assert.ok(!matchesReleaseSurface('.github/actions-foo/x.yml'), + '.github/actions/** must not match the sibling directory .github/actions-foo/'); + assert.ok(!matchesReleaseSurface('crates/mds-napi-extra/src/lib.rs'), + 'crates/mds-napi/** must not match crates/mds-napi-extra/'); + assert.ok(!matchesReleaseSurface('scripts/verify-napi-names.mjs.bak'), + 'an exact pattern must not prefix-match scripts/verify-napi-names.mjs.bak'); + assert.ok(!matchesReleaseSurface('.github/workflows/release.yml.orig'), + 'an exact pattern must not prefix-match .github/workflows/release.yml.orig'); + // The directory itself, with no file under it, is not a changed file path + // GitHub ever reports — but the prefix rule must still be strict about it. + assert.ok(!matchesReleaseSurface('.github/actions'), + '.github/actions/** must not match the bare directory name'); + }); + }); // Code of Conduct tests (AC-1, AC-2) live in code-of-conduct.spec.mjs — diff --git a/scripts/verify-pr-checks.mjs b/scripts/verify-pr-checks.mjs index 00adf632..bc6b94b6 100644 --- a/scripts/verify-pr-checks.mjs +++ b/scripts/verify-pr-checks.mjs @@ -151,13 +151,13 @@ export const TIER_B_EXPECTED_SKIPPED = new Set([ // mis-specified paths filter could pass as a silent no-run (ADR-013 §3). // // RELEASE_SURFACE must equal the workflow's on.pull_request.paths list -// exactly. Spec S12 (release-auth-probe.spec.mjs) enforces that constraint. +// exactly. Spec S10 (release-auth-probe.spec.mjs) compares the two as sets. // --------------------------------------------------------------------------- /** * The set of paths that trigger release.yml on pull_request. * Must equal the `on.pull_request.paths:` list in .github/workflows/release.yml - * (enforced by spec S12 in release-auth-probe.spec.mjs). + * (enforced as a set equality by spec S10 in release-auth-probe.spec.mjs). * * Pattern rules: a pattern ending in `/**` matches any file under that prefix; * all other patterns are exact file path equality (no glob library needed). @@ -543,8 +543,16 @@ export function evaluateChecks({ // When changedFiles is provided and any file matches the release surface, // require each RELEASE_SURFACE_CONTEXTS job to be present and success. // Absence or non-success is FAIL (Tier A semantics applied to release runs). - // When changedFiles is undefined, skip — callers that only have a SHA cannot - // enumerate files without an additional API call. + // + // Duplicate names are resolved the same way Tier A resolves them: EVERY run + // carrying the name must be completed+success. Newest-wins would let a green + // re-run mask a red sibling from another check-suite — a fail-open in a merge + // gate. `filter=latest` already de-duplicates within one suite. + // + // changedFiles === undefined is the CALLER-path skip: evaluateChecks is an + // exported pure function and a caller holding only a SHA cannot enumerate + // files. main() never takes this path — fetchChangedFiles fails closed on an + // API error rather than degrading to undefined. if (changedFiles === undefined) { lines.push( ' · D-PR6: changedFiles not provided — release-surface presence check skipped ' + @@ -794,9 +802,14 @@ export function fetchRequiredContexts(baseBranch, requiredFrom, runner) { /** * Fetch changed-file paths for a PR, paginated (D-PR6). * - * Three outcomes: - * - API error → { ok: 'skip', notice: string } — degrade gracefully (best-effort) - * - Pagination overflow or count mismatch → { ok: false, exitCode: 2, message: string } + * Fails CLOSED on every indeterminate outcome, matching fetchCheckRuns, + * fetchStatuses and fetchRequiredContexts: a transient API error must not let a + * release-surface PR through without its release check-runs. "Cannot tell" is + * exit 2, never a silent skip (applies ADR-009, avoids PF-013). + * + * Two outcomes: + * - API error, pagination overflow, or count mismatch + * → { ok: false, exitCode: 2, message: string } * - Success → { ok: true, files: string[] } * * @param {number|string} prNumber @@ -812,7 +825,17 @@ export function fetchChangedFiles(prNumber, declaredCount, runner) { const url = `/repos/{owner}/{repo}/pulls/${prNumber}/files?per_page=100&page=${page}`; const data = runner(['api', url]); if (data.__error) { - return { ok: 'skip', notice: `PR files API error (page ${page}): ${data.stderr}` }; + // Fail closed. Skipping the release-surface check here would mean a + // 500 from the files endpoint silently converts a PR that MUST carry + // release check-runs into one that needs none — a fail-open in a merge + // gate, which is the whole defect class this tool exists to remove. + return { + ok: false, + exitCode: 2, + message: + `PR files API error (page ${page}): ${data.stderr} — cannot determine whether ` + + `this PR touches the release surface, and "cannot tell" is not a pass (D-PR6)`, + }; } const items = Array.isArray(data) ? data : (data.files ?? []); files.push(...items.map(f => f.filename)); @@ -943,20 +966,16 @@ export function main(argv = process.argv.slice(2), runner = defaultGhRunner, ghV } // ---- D-PR6: fetch changed files for release-surface presence check ---- - // API errors degrade gracefully (skip the check); pagination overflow and - // count mismatches fail closed (exit 2, non-vacuity — avoids PF-013). + // Fails closed on every indeterminate outcome (API error, pagination + // overflow, count mismatch), exactly like the three fetches above: the live + // path always knows the file list or exits 2 (avoids PF-013). const filesResult = fetchChangedFiles(prNumber, prData.changed_files ?? null, runner); - let changedFiles; - if (filesResult.ok === false) { + if (!filesResult.ok) { fail(filesResult.message); return filesResult.exitCode; - } else if (filesResult.ok === 'skip') { - console.log(` · D-PR6: changed-files API error — release-surface check skipped: ${filesResult.notice}`); - changedFiles = undefined; - } else { - changedFiles = filesResult.files; - console.log(` changed files: ${changedFiles.length}`); } + const changedFiles = filesResult.files; + console.log(` changed files: ${changedFiles.length}`); // ---- Evaluate (D-PR1: pure function) ---- const result = evaluateChecks({ From c7f68b5623b6ab6a57e5fb0030705a41fa6c2798 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 7 Sep 2026 01:18:57 +0300 Subject: [PATCH 4/6] =?UTF-8?q?fix(ci):=20align=20B1=20with=20the=20approv?= =?UTF-8?q?ed=20plan=20=E2=80=94=20restore=20PR-time=20credential=20probes?= =?UTF-8?q?,=20fail=20closed=20on=20fork/Dependabot=20PRs,=20decouple=20th?= =?UTF-8?q?e=20rehearsal=20from=20the=20napi=20matrix,=20harden=20gate=20c?= =?UTF-8?q?ontrols=20(#342,=20#350)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1: Remove step-level if: from credential and PyPI OIDC probe steps in version-gate (both now run on every event, including pull_request). Remove the notice step that announced the skipping. M2: Add IS_FORK env + fail-closed block before the npm-token guard. Fork and Dependabot pull_request events receive no repository secrets and no id-token: write; the probe exits 1 with an actionable error pointing to `gh workflow run release.yml --ref ` as the remedy. Removes the now-incorrect NOTE suffix on the NPM_TOKEN error. (Applying ADR-013) M3: rehearse-publish-python: needs [build-python] only (decoupled from the napi matrix); job-level if conditioned on build-python success. M4+M5+M6: publish-testpypi: needs [build-python, rehearse-publish-python]; if guarded by workflow_dispatch + inputs.testpypi + both upstreams; new "Assert all 8 Python distributions are present" step before publish. M7: Gate 1 — add dc37677b2e1c63e2034f94d8a5b11f265b73ba33 (v1.14.2 commit SHA) as second positive control. Update error message to explain why GHCR has images for commit shas AND release tags but never annotated tag objects. Fix publish-python comment (no longer a blanket "GHCR only publishes images for tagged releases" claim). M8: publish-python: add rehearse-publish-python to needs and if condition. M9/S6: rehearse-publish-python job-level if now tested as a non-tag-guarded condition on build-python success (was: asserting if is null). M10a/b/c: Three new ADR-013 graph tests — cargo-publish jobs transitively need rehearse-publish-python; publish-crates does NOT transitively need publish-testpypi; guarded job names exactly equal TIER_B_EXPECTED_SKIPPED. M11: Gate 2 — GHCR probe now writes body to /tmp/ghcr-body.json; positive control checks HTTP 404 AND MANIFEST_UNKNOWN body text (not just non-200). M12: Gate 3 — positive control uses grep for "manifest unknown" rather than bare exit code; docker pull retries up to 3 times with sleep 10 between attempts. M13: Extend file header comment to describe the paths: list as the release surface. M14: RELEASING.md — update probes description (run on all events, fail closed on forks); TestPyPI publisher expiry note; update "What happens after tagging" already-applied items cross-checked. M15: CHANGELOG.md [Unreleased] entry updated to reflect aligned behaviour. M16: rename D-PR6 presence-check to D-PR7 (D-PR6 stays for Exit codes). M17: D-PR5a test updated to "five publish names" (was "four"). P: Fix empty GitHub expression ${{ }} in Gate 3 bash comment — GitHub's expression pre-processor is not comment-aware; it raises "An expression was expected" (run 34061583304, 0 jobs). Rewrite comment to use plain prose. Spec S19 guards against regression. Applying PF-013 (positive controls), PF-039 (unguarded rehearsal), PF-040 (release-tag pin), ADR-013 (three-place rule: guarded jobs, TIER_B_EXPECTED_SKIPPED, verifier required contexts). --- .github/workflows/release.yml | 181 +++++++------ CHANGELOG.md | 2 +- RELEASING.md | 22 +- scripts/__test__/release-auth-probe.spec.mjs | 251 ++++++++++++++++++- scripts/__test__/verify-pr-checks.spec.mjs | 54 ++-- scripts/verify-pr-checks.mjs | 27 +- 6 files changed, 407 insertions(+), 130 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a7ee384..8f2f2d91 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,14 @@ name: Release # * pull_request touching release-surface paths -> rehearse only, no publish # * workflow_dispatch -> dry run (build + verify, no publish) # testpypi: true -> same + publish to TestPyPI (opt-in leg) +# +# The `paths:` list IS the release surface. Publish jobs stay dark on PRs +# (refs/pull/N/merge fails the refs/tags/v tag guard -> skipped, tolerated by +# scripts/verify-pr-checks.mjs for exactly the five enumerated job names in +# TIER_B_EXPECTED_SKIPPED). crates/mds-core/**, Cargo.toml, and package.json +# are excluded on purpose: they change on most PRs and ci.yml already covers +# them; a dependency sweep that does not touch the five paths below still needs +# the manual workflow_dispatch dry-run. on: push: tags: @@ -55,28 +63,26 @@ jobs: ref: ${{ github.ref }} - uses: actions/setup-node@v7 with: { node-version: 22, registry-url: "https://registry.npmjs.org" } - # Skipped on pull_request (step-level, not job-level — ADR-013 amendment - # 2026-09-06: a job present on a PR head must reach conclusion=success or - # the mandatory pre-merge verifier hard-fails the very PR that changes the - # release surface, and the operator's next move is to bypass the verifier, - # which is the PF-017 shape). Fork and Dependabot pull_request events - # receive NO repository secrets and no id-token: write, so these probes - # CANNOT pass there — and a Dependabot bump to a publish-python action is - # precisely the PR class #350 exists to catch (PF-039 amendment - # 2026-09-05). Skipping loses nothing: no pull_request run can reach a - # registry write. The probes still run, and still fail closed, on tag push - # and workflow_dispatch — the only events that publish. - name: "Verify publish credentials before irreversible steps (security-08)" - if: github.event_name != 'pull_request' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} CARGO_REG_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + IS_FORK: ${{ github.event.pull_request.head.repo.fork }} run: | + # Fail closed on fork and Dependabot pull_request events: these receive + # no repository secrets and no id-token: write (read-only GITHUB_TOKEN), + # so the credential probes cannot succeed there. A maintainer-authored PR + # or a manual `gh workflow run release.yml --ref ` is the remedy. + if [ -z "$NODE_AUTH_TOKEN" ] && [ "${GITHUB_EVENT_NAME}" = "pull_request" ] \ + && { [ "${GITHUB_ACTOR}" = "dependabot[bot]" ] || [ "${IS_FORK}" = "true" ]; }; then + echo "::error::No Actions secrets on this run: pull_request runs from Dependabot or a fork are treated as fork PRs (read-only token, no Actions secrets, no id-token: write), so the release credentials cannot be verified here. Do NOT merge on these checks. Supersede with a maintainer-authored PR, or run: gh workflow run release.yml --ref ${GITHUB_HEAD_REF}" + exit 1 + fi # Guard: npm token must be non-empty. A missing or revoked secret interpolates # to an empty string in GitHub Actions — not an error — so this must be checked # explicitly before cargo publish runs and makes crates.io publish irreversible. if [ -z "$NODE_AUTH_TOKEN" ]; then - echo "::error::NPM_TOKEN is empty or unset — npm publish would fail AFTER cargo publish (irreversible). Set the NPM_TOKEN repo secret. NOTE: Dependabot and fork pull_request events do not receive repository secrets; only first-party branch PRs do. (security-08)" + echo "::error::NPM_TOKEN is empty or unset — npm publish would fail AFTER cargo publish (irreversible). Set the NPM_TOKEN repo secret. (security-08)" exit 1 fi # Guard: cargo token must be non-empty. A missing token fails before the @@ -99,11 +105,7 @@ jobs: # NOT tag-guarded: this step must run in the workflow_dispatch dry run # (PF-039) so a misconfigured or expired trusted publisher is caught BEFORE # any irreversible crates.io publish. version-gate has id-token: write above. - # Skipped on pull_request for the same reason as the credential probe: a - # fork PR's GITHUB_TOKEN is read-only regardless of the permissions block, - # so ACTIONS_ID_TOKEN_REQUEST_URL is unset and the step cannot succeed. - name: "Verify PyPI trusted publisher before irreversible steps (security-08)" - if: github.event_name != 'pull_request' shell: bash run: | set -euo pipefail @@ -117,10 +119,6 @@ jobs: echo "::error::PyPI OIDC mint failed (HTTP $CODE) — trusted publisher missing, expired, or mismatched. Fix BEFORE tagging; crates.io is irreversible." jq -r '.message // .' /tmp/mint.json; exit 1; } echo "PyPI trusted publisher OK" - - name: "Credential + OIDC probes skipped on pull_request (step-level guard — ADR-013 amendment)" - if: github.event_name == 'pull_request' - run: | - echo "::notice::Credential and PyPI OIDC probes skipped for pull_request events — fork and Dependabot PRs receive no repository secrets and no id-token, and no PR run can reach a registry write. Both probes run and fail closed on tag push and workflow_dispatch." # The pypa/gh-action-pypi-publish pin is validated in rehearse-publish-python # (pin shape, GHCR manifest, docker pull, twine check — each with a positive # control). publish-crates needs that job, so a bad pin still aborts the @@ -138,15 +136,11 @@ jobs: - name: "Run positive-control and class-completeness suite" run: npm run test:gates # reliability-12: assert the SHA being released went green in CI before - # any irreversible publish starts. Runs on tag push and workflow_dispatch - # (dry run) - a tag push verifies the tagged commit, a dispatch verifies - # the dispatched ref's HEAD. Skipped on pull_request events: a PR's merge - # commit may not have a ci.yml run yet, and PRs exercise the rehearsal - # jobs rather than the full publish sequence. Step-level if: (not - # job-level) keeps version-gate itself running (and succeeding) on PRs so - # Tier-B verifier sees a success, not a skipped result (ADR-013 amendment - # 2026-09-06). Cancelled, failed, in-progress and absent runs all fail - # closed on tag/dispatch (PF-017; PF-013: absence is not success). + # any irreversible publish starts. Runs on BOTH triggers - a tag push + # verifies the tagged commit, a workflow_dispatch dry run verifies the + # dispatched ref's HEAD - so the dry run now exercises this gate instead + # of skipping it. Cancelled, failed, in-progress and absent runs all fail + # closed (PF-017; PF-013: absence is not success). # Cannot reuse scripts/verify-pr-checks.mjs here - that script requires a # PR number, and tag pushes reference a commit, not a PR. # Branch protection is NOT read: /branches/main/protection needs @@ -725,7 +719,8 @@ jobs: # --------------------------------------------------------------------------- rehearse-publish-python: name: Rehearse PyPI publish (no upload) - needs: [version-gate, stage-and-verify-napi, build-python] + needs: [build-python] + if: ${{ !cancelled() && needs.build-python.result == 'success' }} runs-on: ubuntu-latest permissions: # Deliberately NO OIDC write permission here. A job that cannot mint a @@ -772,18 +767,21 @@ jobs: # crates.io and 15 npm packages had published irreversibly. is_release_tag() { [[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; } - # Positive control first (PF-013): the v0.4.1 pin must be REJECTED, or - # this gate is a no-op that would accept the exact value that broke a - # release. a892a5a6 is the annotated tag OBJECT of v1.14.2. - BAD_PIN=a892a5a61159132606e93a2fa6f4358831b04d26 - if is_release_tag "${BAD_PIN}"; then - echo "::error::positive control FAILED — the pin-shape gate accepted the v0.4.1 annotated-tag-object SHA ${BAD_PIN}. The gate is broken, not the pin (PF-013)." - exit 1 - fi - echo "positive control OK: ${BAD_PIN} rejected by the pin-shape gate" + # Positive control first (PF-013): BOTH known-bad pins must be REJECTED. + # a892a5a6 is the annotated tag OBJECT of v1.14.2 (the v0.4.1 break). + # dc37677b is the COMMIT sha of v1.14.2 — a real, image-backed commit + # sha — the shape check must reject it too (the gate enforces policy, + # not existence: a commit sha may have an image but is ambiguous by eye). + for BAD_PIN in a892a5a61159132606e93a2fa6f4358831b04d26 dc37677b2e1c63e2034f94d8a5b11f265b73ba33; do + if is_release_tag "${BAD_PIN}"; then + echo "::error::positive control FAILED — the pin-shape gate accepted the SHA ${BAD_PIN}. The gate is broken, not the pin (PF-013)." + exit 1 + fi + echo "positive control OK: ${BAD_PIN} rejected by the pin-shape gate" + done if ! is_release_tag "${PIN_REF}"; then - echo "::error::pypa/gh-action-pypi-publish pin '${PIN_REF}' is not a vX.Y.Z release tag. GHCR publishes images for release tags only, so a SHA pin cannot resolve to an image under any circumstance (PF-040)." + echo "::error::pypa/gh-action-pypi-publish is pinned to '${PIN_REF}', which is not a vX.Y.Z release tag. The action resolves github.action_ref into a GHCR image tag; GHCR has images for release tags and for commit shas on upstream release/* branches but NEVER for an annotated tag object, and a 40-hex pin is indistinguishable by eye between those two. Tag-pin it (PF-040)." exit 1 fi echo "pin shape OK: ${PIN_REF}" @@ -796,10 +794,10 @@ jobs: # GHCR the same question the runner will ask at publish time — the # only question that matters. "The ref resolves in git" is necessary # and never sufficient (PF-040). - TOKEN=$(curl -sS --fail --max-time 30 \ - 'https://ghcr.io/token?scope=repository:pypa/gh-action-pypi-publish:pull' \ - | jq -r '.token') - [ -n "${TOKEN}" ] && [ "${TOKEN}" != "null" ] \ + TOKEN=$(curl -sS --max-time 30 --retry 3 --retry-delay 5 \ + "https://ghcr.io/token?service=ghcr.io&scope=repository:pypa/gh-action-pypi-publish:pull" \ + | jq -r '.token // empty') + [ -n "${TOKEN}" ] \ || { echo "::error::could not obtain an anonymous GHCR pull token"; exit 1; } # No --fail here: a 404 is a RESULT to report, not a curl error to @@ -810,45 +808,67 @@ jobs: # manifest served as the wrong type would answer 404 for an image that # exists — a false failure in a release gate. ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json' - manifest_status() { - curl -sS -o /dev/null -w '%{http_code}' --max-time 30 \ + manifest_fetch() { + curl -sS -o /tmp/ghcr-body.json -w '%{http_code}' --max-time 30 \ -H "Authorization: Bearer ${TOKEN}" \ -H "Accept: ${ACCEPT}" \ "https://ghcr.io/v2/pypa/gh-action-pypi-publish/manifests/$1" } - # Positive control (PF-013): a ref that cannot exist must NOT be 200. - CONTROL=$(manifest_status "${BOGUS_REF}") - if [ "${CONTROL}" = "200" ]; then - echo "::error::positive control FAILED — GHCR returned 200 for the bogus ref ${BOGUS_REF}. This probe cannot tell a present image from an absent one (PF-013)." + # Positive control (PF-013): the control ref must return 404 AND the + # body must contain MANIFEST_UNKNOWN. If either condition fails, the + # probe cannot detect a missing image and every result is indeterminate. + CTRL_CODE=$(manifest_fetch "${BOGUS_REF}-positive-control-does-not-exist") + if [ "${CTRL_CODE}" != "404" ] || ! grep -qiF 'MANIFEST_UNKNOWN' /tmp/ghcr-body.json; then + echo "::error::GHCR probe cannot detect a missing image (control returned HTTP ${CTRL_CODE}); every result from this step is indeterminate, not a pass (PF-013)." + cat /tmp/ghcr-body.json exit 1 fi - echo "positive control OK: ${BOGUS_REF} → HTTP ${CONTROL} (not 200)" + echo "Positive control OK: missing manifests are detected." - STATUS=$(manifest_status "${PIN_REF}") + STATUS=$(manifest_fetch "${PIN_REF}") if [ "${STATUS}" != "200" ]; then - echo "::error::GHCR has no image for ${PUBLISH_IMAGE}:${PIN_REF} (HTTP ${STATUS}). publish-python would fail with 'manifest unknown' AFTER crates.io and npm have published irreversibly (PF-040). Update PIN_REF and the uses: pins in publish-python and publish-testpypi together." + echo "::error::GHCR has no image for ${PUBLISH_IMAGE}:${PIN_REF} (HTTP ${STATUS}). publish-python would fail with 'manifest unknown' AFTER crates.io and npm published irreversibly — the v0.4.1 failure (#350, PF-040). Update PIN_REF and the uses: pins in publish-python and publish-testpypi together." + cat /tmp/ghcr-body.json exit 1 fi + echo "GHCR manifest present for ghcr.io/pypa/gh-action-pypi-publish:${PIN_REF}" echo "GHCR manifest OK: ${PUBLISH_IMAGE}:${PIN_REF} → HTTP ${STATUS}" - name: "Gate 3/4: docker pull the image the publish step will run (PF-040)" shell: bash run: | set -euo pipefail + IMAGE="${PUBLISH_IMAGE}:${PIN_REF}" # Positive control (PF-013): pulling a ref that cannot exist must fail. - if docker pull --quiet "${PUBLISH_IMAGE}:${BOGUS_REF}" >/dev/null 2>&1; then - echo "::error::positive control FAILED — docker pull succeeded for the bogus ref ${BOGUS_REF}. The pull gate proves nothing (PF-013)." + CTRL_LOG=$(docker pull "${PUBLISH_IMAGE}:${BOGUS_REF}" 2>&1) || true + if echo "${CTRL_LOG}" | grep -qiE 'manifest unknown|not found|denied|unauthorized'; then + echo "Positive control OK: docker pull rejects a missing tag." + else + echo "::error::control pull failed for an unexpected reason (not a registry miss) - indeterminate, not a pass." + echo "${CTRL_LOG}" exit 1 fi - echo "positive control OK: docker pull ${PUBLISH_IMAGE}:${BOGUS_REF} failed as expected" - docker pull --quiet "${PUBLISH_IMAGE}:${PIN_REF}" - # Go template, not a GitHub expression: Actions only interpolates ${{ }}, - # so a bare {{ }} passes through untouched. `json` rather than - # `index .RepoDigests 0` because index on an empty slice is a template - # error, and a diagnostic print must not be able to fail the gate. - docker image inspect "${PUBLISH_IMAGE}:${PIN_REF}" \ - --format 'pulled digests: {{json .RepoDigests}}' + # Pull the real image with up to 3 attempts (transient registry blips). + PULLED=0 + for i in 1 2 3; do + if timeout 300 docker pull --quiet "${IMAGE}"; then + PULLED=1 + break + fi + echo "::notice::docker pull attempt ${i}/3 failed for ${IMAGE}; retrying in 10 s..." + sleep 10 + done + [ "${PULLED}" -eq 1 ] || { + echo "::error::docker pull failed for ${IMAGE} after 3 attempts" + exit 1 + } + # Go template braces, not an Actions expression: Actions only interpolates + # dollar-double-brace sequences, so {{json .RepoDigests}} is passed to + # docker untouched. `json` rather than `index .RepoDigests 0` because + # index on an empty slice is a template error, and a diagnostic print + # must not be able to fail the gate. + docker image inspect "${IMAGE}" --format 'pulled digests: {{json .RepoDigests}}' - name: "Gate 4/4: twine check the distributions (no upload, no network)" shell: bash run: | @@ -895,8 +915,8 @@ jobs: # --------------------------------------------------------------------------- publish-testpypi: name: Publish to TestPyPI (rehearsal) - needs: [version-gate, stage-and-verify-napi, build-python] - if: ${{ inputs.testpypi == true }} + needs: [build-python, rehearse-publish-python] + if: ${{ !cancelled() && needs.build-python.result == 'success' && needs.rehearse-publish-python.result == 'success' && github.event_name == 'workflow_dispatch' && inputs.testpypi == true }} runs-on: ubuntu-latest permissions: id-token: write # OIDC for TestPyPI trusted publisher @@ -908,6 +928,16 @@ jobs: pattern: python-* path: python-dist/ merge-multiple: true + - name: Assert all 8 Python distributions are present + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + ls -la python-dist/ + whl=(python-dist/*.whl); sd=(python-dist/*.tar.gz) + [ "${#whl[@]}" -eq 7 ] || { echo "::error::expected 7 wheels, found ${#whl[@]}"; exit 1; } + [ "${#sd[@]}" -eq 1 ] || { echo "::error::expected 1 sdist, found ${#sd[@]}"; exit 1; } + echo "Python distributions complete: ${#whl[@]} wheels + ${#sd[@]} sdist" - name: Publish to TestPyPI # IMPORTANT: Do NOT change this pin back to a commit SHA — see the # extended comment in the publish-python step below (PF-040). @@ -1147,8 +1177,8 @@ jobs: # --------------------------------------------------------------------------- publish-python: name: Publish to PyPI - needs: [build-python, publish-crates, publish-npm] - if: ${{ !cancelled() && needs.build-python.result == 'success' && needs.publish-crates.result == 'success' && needs.publish-npm.result == 'success' && startsWith(github.ref, 'refs/tags/v') }} + needs: [build-python, rehearse-publish-python, publish-crates, publish-npm] + if: ${{ !cancelled() && needs.build-python.result == 'success' && needs.rehearse-publish-python.result == 'success' && needs.publish-crates.result == 'success' && needs.publish-npm.result == 'success' && startsWith(github.ref, 'refs/tags/v') }} runs-on: ubuntu-latest permissions: id-token: write # OIDC for PyPI trusted publishing + PEP 740 attestations @@ -1179,12 +1209,15 @@ jobs: # IMPORTANT: Do NOT change this pin back to a commit SHA (PF-040). # pypa/gh-action-pypi-publish is a Docker-based composite action that # derives its GHCR image tag from `github.action_ref`. When you pin via - # `@`, GitHub Actions sets action_ref to that SHA, and the action - # pulls ghcr.io/pypa/gh-action-pypi-publish:. GHCR only publishes - # images for tagged releases — not for every commit SHA. - # The v0.4.1 release used `@a892a5a...`, which is the annotated *tag - # object* SHA for v1.14.2 (not the commit SHA). No GHCR image exists for - # it, so the publish-python job failed with "manifest unknown". + # `@`, GitHub Actions sets action_ref to that ref, and the action + # pulls ghcr.io/pypa/gh-action-pypi-publish:. GHCR carries images + # for release tags, the vX / vX.Y aliases, AND commit SHAs that landed + # on upstream release/* branches (the v1.14.2 commit dc37677b returns + # HTTP 200) — but NEVER for an annotated tag object (a892a5a6 = 404, + # the v0.4.1 failure). A 40-hex pin is indistinguishable by eye between + # the two cases, which is why the policy requires a vX.Y.Z release tag. + # `rehearse-publish-python` enforces this as a gate on every PR, dry run + # and tag push (#350, PF-040). # Using the release tag name is unambiguous. rehearse-publish-python # proves this exact ref resolves to a pullable GHCR image (and that twine # accepts the distributions) before publish-crates makes crates.io diff --git a/CHANGELOG.md b/CHANGELOG.md index c406fa4c..b885ef47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cargo dependency sweep: napi 3.9.0 → 3.12.2, napi-derive 3.5.6 → 3.6.3, napi-build 2.3.2 → 2.4.1 (napi-sys 3.3.0, napi-derive-backend 6.1.2), pyo3 0.29.0 → 0.29.2, clap 4.6.1 → 4.6.6, similar 3.1.1 → 3.2.0, wasm-bindgen 0.2.121 → 0.2.126 (js-sys 0.3.103, wasm-bindgen-futures 0.4.76, wasm-bindgen-test 0.3.76), serde 1.0.228 → 1.0.229, serde_json 1.0.150 → 1.0.151, thiserror 2.0.18 → 2.0.20, libc 0.2.186 → 0.2.189. Supersedes Dependabot #354 #360 #359 #358 #280 #251 #249 #246 #243. - npm dependency sweep: relaxed the three phantom floor pins to caret ranges — fast-uri 3.1.5 → ^3.1.6 (oldest release patching GHSA-5jgf-p345-68v8, GHSA-fph4-wmhf-6fwf, GHSA-f65p-4m7j-42xc, GHSA-jqff-g426-hqxp), nanoid 3.3.18 → ^3.3.18, js-yaml 4.3.1 → ^4.3.1 (#336); @napi-rs/cli ^3.0.0 → ^3.8.6 (lock 3.7.0 → 3.8.6); vite lock 8.1.5 → 8.2.2; Dependabot `ignore` rules for semver-major bumps of the three phantom pins. Supersedes Dependabot #315 #332 #346 #362 #355 #357 #279. - GitHub Actions sweep: actions/checkout v6 → v7 (16 call sites: 9 ci.yml + 7 release.yml), actions/setup-node v6 → v7 (6 sites), actions/setup-python v5 → v7 (5 sites, ci.yml only; action runtime node20 → node24), PyO3/maturin-action pin normalized from the v1.51.0 annotated-tag object (`3e2bdf6`) to the commit it points to (`e83996d1`), same version (PF-040); Dependabot `ignore` for typescript semver-major version updates pending the TS 7 migration (#364). Supersedes Dependabot #111, #189, #241, #356; replaces #169. -- Release-surface PR gate: `release.yml` now triggers on `pull_request` events touching `.github/workflows/release.yml`, `.github/actions/**`, `crates/mds-napi/**`, `crates/mds-python/**`, and `scripts/verify-napi-names.mjs`; adds an unguarded `rehearse-publish-python` job (PF-039) that rehearses `publish-python` without uploading — pin shape, GHCR manifest, `docker pull` and `twine check` run from the pinned image with `--network none`, each with a positive control (PF-013, PF-040) — plus a `publish-testpypi` opt-in leg; the rehearsal never invokes `pypa/gh-action-pypi-publish` (the action has no dry-run mode: an unrecognised input is ignored and it uploads for real) and is denied `id-token`, both pinned by specs S14/S15; credential, OIDC and CI-history gates in `version-gate` are skipped at step level on `pull_request` so fork and Dependabot PRs reach `success` (ADR-013 amendment); `verify-pr-checks.mjs` requires `Version gate`, `Stage + verify platform packages` and `Rehearse PyPI publish (no upload)` on release-surface PRs and fails closed when the changed-file list cannot be read (#342, #350). +- Release-surface PR gate: `release.yml` now triggers on `pull_request` events touching `.github/workflows/release.yml`, `.github/actions/**`, `crates/mds-napi/**`, `crates/mds-python/**`, and `scripts/verify-napi-names.mjs`; adds an unguarded `rehearse-publish-python` job (PF-039) that rehearses `publish-python` without uploading — pin shape (both the annotated-tag-object SHA a892a5a6 and the commit SHA dc37677b are rejected as positive controls), GHCR manifest (404 + `MANIFEST_UNKNOWN` body required), bounded `docker pull` (3-attempt loop), and `twine check` from the pinned image with `--network none`, each with a positive control (PF-013, PF-040) — plus a `publish-testpypi` opt-in leg (dispatch-guarded); the rehearsal never invokes `pypa/gh-action-pypi-publish` (the action has no dry-run mode) and is denied `id-token`, both pinned by specs S14/S15; credential and OIDC probes run on `pull_request` events and fail closed on fork/Dependabot PRs (no secrets, no `id-token: write`) with an actionable error; `verify-pr-checks.mjs` requires `Version gate`, `Stage + verify platform packages` and `Rehearse PyPI publish (no upload)` on release-surface PRs and fails closed when the changed-file list cannot be read (#342, #350). ## [0.4.2] — 2026-09-03 diff --git a/RELEASING.md b/RELEASING.md index d88f942d..66ea95af 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -58,8 +58,9 @@ These are **not** automated and must be done before the first release: npm publish runs. The minted token expires unused — the probe is free and safe. This step is NOT tag-guarded so it runs in the `workflow_dispatch` dry run too, exercising the PyPI trust chain before the real tag push (PF-039). It is - skipped on `pull_request` events, which receive no `id-token` on forks and - cannot reach a publish in any case. + run on `pull_request` events too — the version-gate step fails closed on + fork and Dependabot PRs (they receive no `id-token: write` and no + repository secrets), and no PR run can reach a publish in any case. 6. **Configure TestPyPI trusted publisher** (optional, needed for `testpypi: true` dispatch runs) at [test.pypi.org/manage/account/publishing](https://test.pypi.org/manage/account/publishing/): @@ -74,6 +75,11 @@ These are **not** automated and must be done before the first release: skipped conclusion. The trusted publisher for TestPyPI is independent of the PyPI one — both must be configured separately. + **Publisher expiry:** a PyPI pending publisher auto-expires ~30 days after + creation unless an upload lands. For TestPyPI, the first `workflow_dispatch` + run with `testpypi: true` is what locks the name — it is the first upload. + Re-create the publisher if it has expired before that first dispatch. + ## Pre-flight (before tagging) Run the local dry-runs and gates: @@ -151,12 +157,12 @@ for non-empty, and probes the PyPI trusted publisher via the OIDC mint-token exchange. A revoked token, absent secret, or misconfigured trusted publisher therefore fails the dry run — all before any irreversible crates.io release. -Both probes are **skipped on `pull_request` events** (step-level, so -`version-gate` itself still runs and succeeds — ADR-013 amendment). Fork and -Dependabot PRs receive no repository secrets and no `id-token: write`, so the -probes cannot pass there, and no PR run can reach a registry write. They run -and fail closed on tag push and `workflow_dispatch`, which are the only events -that publish. +Both probes **run on every event, including `pull_request`**. On fork and +Dependabot PRs — which receive no repository secrets and no `id-token: +write` — the probes fail closed with an actionable error: maintainers must +supersede with a first-party branch PR or dispatch `gh workflow run +release.yml --ref `. No PR run can reach a publish in any case, +so the fail-closed behaviour is informational, not a merge blocker by itself. **Note:** `npm whoami` verifies authentication, not publish rights to the `@mdscript` scope. A read-only or wrongly-scoped token passes the probe but diff --git a/scripts/__test__/release-auth-probe.spec.mjs b/scripts/__test__/release-auth-probe.spec.mjs index ec72aeef..295881d7 100644 --- a/scripts/__test__/release-auth-probe.spec.mjs +++ b/scripts/__test__/release-auth-probe.spec.mjs @@ -326,22 +326,38 @@ describe('B1: release-surface PR gate and rehearsal jobs', () => { }); // ------------------------------------------------------------------------- - // S6: rehearse-publish-python must have NO job-level if: guard. - // The job must run on pull_request, workflow_dispatch, AND tag push so that - // the OIDC exchange is validated before any irreversible crates.io publish. - // A job-level tag guard would re-introduce PF-039 for this job. + // S6: rehearse-publish-python must have a job-level if: that is conditioned + // on needs.build-python.result == 'success' but NOT on refs/tags/v or any + // startsWith(github.ref) guard, so PRs and dispatches both exercise it. + // + // PF-039 rationale: a tag guard would make rehearse-publish-python skip on + // pull_request, removing the only pre-tag validation of the pypa action pin + // and GHCR image. The job must run on all three triggers; using a needs + // result-condition (not an event guard) is the correct pattern. // ------------------------------------------------------------------------- - test('S6: rehearse-publish-python has no job-level if: (runs on PR, dispatch, and tag push)', () => { + test('S6: rehearse-publish-python has a job-level if: conditioned on build-python success (not a tag guard)', () => { const section = extractJobSection(yml, 'rehearse-publish-python'); assert.ok(section !== null, 'rehearse-publish-python must exist (see S5)'); const jobIf = extractJobIf(section); - assert.equal( - jobIf, null, - 'rehearse-publish-python must have NO job-level if: guard — it must run on all ' + - 'triggering events (pull_request, workflow_dispatch, push) so PRs exercise the ' + - 'OIDC exchange before the irreversible crates.io publish (PF-039); ' + + assert.ok( + jobIf !== null, + 'rehearse-publish-python must have a job-level if: conditioned on needs.build-python.result ' + + '(the job is decoupled from stage-and-verify-napi so a napi failure cannot block the rehearsal); ' + `got if: ${jobIf}`, ); + assert.ok( + jobIf.includes("needs.build-python.result == 'success'"), + 'rehearse-publish-python if: must contain needs.build-python.result == \'success\'; ' + + `got: ${jobIf}`, + ); + // The guard must NOT be a refs/tags/v or startsWith(github.ref) condition — + // those would re-introduce PF-039 by skipping on pull_request events. + assert.ok( + !jobIf.includes('refs/tags') && !jobIf.includes('startsWith(github.ref'), + 'rehearse-publish-python if: must NOT contain a refs/tags/v or startsWith guard — ' + + 'that would skip on pull_request, removing pre-tag validation (PF-039); ' + + `got: ${jobIf}`, + ); }); // ------------------------------------------------------------------------- @@ -681,6 +697,24 @@ describe('B1: release-surface PR gate and rehearsal jobs', () => { `pulls (PF-040). Bump both together.`, ); } + + // The single distinct action ref must be a vX.Y.Z release tag (M17 / PF-040). + // The gate enforces policy — not just existence — so a commit SHA or annotated-tag + // object SHA that happens to have a GHCR image is not acceptable (the policy is + // "pin by release tag name so humans can read the version at a glance"). + const distinctRefs = [...new Set(refs)]; + assert.equal( + distinctRefs.length, 1, + `all pypa/gh-action-pypi-publish uses must pin the same ref; found: [${distinctRefs.join(', ')}]`, + ); + assert.ok( + /^v\d+\.\d+\.\d+$/.test(distinctRefs[0]), + `the single distinct pypa/gh-action-pypi-publish pin "${distinctRefs[0]}" must match ` + + '/^v\\d+\\.\\d+\\.\\d+$/ (a vX.Y.Z release tag) — a commit SHA or annotated-tag object ' + + 'SHA is indistinguishable by eye but has no GHCR image for annotated objects (PF-040); ' + + `positive control: "a892a5a61159132606e93a2fa6f4358831b04d26" must be REJECTED ` + + `(it matches /^[0-9a-f]{40}$/ but not /^v\\d+\\.\\d+\\.\\d+$/)`, + ); }); // ------------------------------------------------------------------------- @@ -778,4 +812,201 @@ describe('B1: release-surface PR gate and rehearsal jobs', () => { } }); + // ------------------------------------------------------------------------- + // M10a: every job whose section contains `cargo publish` must transitively + // need rehearse-publish-python, so a failed OIDC exchange aborts before the + // irreversible crates.io write (PF-039/PF-023). + // + // Non-vacuity: assert at least one such job exists (PF-013). + // Positive control: a graph where publish-crates needs only version-gate + // must be flagged (rehearsal edge is absent → not transitive). + // ------------------------------------------------------------------------- + test('M10a: every cargo-publish job transitively needs rehearse-publish-python', () => { + const graph = buildNeedsGraph(yml); + assert.ok( + graph.has('rehearse-publish-python'), + 'non-vacuity: rehearse-publish-python must be a graph node; PF-013', + ); + + const allIds = findAllJobIds(yml); + const cargoPublishJobs = allIds.filter(id => { + // Use stripCommentLines and then look for lines where "cargo publish" is + // the actual command being run (not in echo strings or error messages). + // A line that is the command starts with leading whitespace then "cargo ", + // as opposed to being inside an echo/error string. + const section = stripCommentLines(extractJobSection(yml, id) ?? ''); + return section.split('\n').some(line => { + const trimmed = line.trimStart(); + return trimmed.startsWith('cargo publish') || + // inside if/OUTPUT=$(...) patterns + trimmed.includes('cargo publish -p ') || + // bare cargo publish invocation + /^\s*cargo publish\b/.test(line); + }); + }); + assert.ok( + cargoPublishJobs.length > 0, + 'non-vacuity (PF-013): at least one job must contain "cargo publish"; found none', + ); + + // Positive control: a graph without the rehearsal edge must be flagged. + const controlGraph = new Map([ + ['publish-crates', new Set(['version-gate'])], + ['version-gate', new Set()], + ['rehearse-publish-python', new Set(['build-python'])], + ['build-python', new Set(['version-gate'])], + ]); + assert.ok( + !transitivelyNeeds(controlGraph, 'publish-crates', 'rehearse-publish-python'), + 'positive control: graph without rehearsal edge must report NOT transitive (PF-013)', + ); + + for (const id of cargoPublishJobs) { + assert.ok( + transitivelyNeeds(graph, id, 'rehearse-publish-python'), + `job "${id}" contains "cargo publish" but does not transitively need ` + + `rehearse-publish-python — a failed GHCR or twine rehearsal cannot abort ` + + `before the crates.io write (irreversible, PF-039)`, + ); + } + }); + + // ------------------------------------------------------------------------- + // M10b: publish-crates must NOT transitively need publish-testpypi. + // publish-testpypi is the opt-in TestPyPI leg and must never gate the tag + // path — a failing or skipped TestPyPI upload must not block a release. + // + // Positive control: a graph with publish-crates → publish-testpypi must be + // flagged as transitive (so we know the check can actually detect the edge). + // ------------------------------------------------------------------------- + test('M10b: publish-crates must NOT transitively need publish-testpypi (opt-in leg must never gate the tag path)', () => { + const graph = buildNeedsGraph(yml); + assert.ok( + graph.has('publish-crates'), + 'non-vacuity: publish-crates must be a graph node; PF-013', + ); + assert.ok( + graph.has('publish-testpypi'), + 'non-vacuity: publish-testpypi must be a graph node; PF-013', + ); + + // Positive control: adding the edge must make the check fire. + const controlGraph = new Map([...graph].map(([k, v]) => [k, new Set(v)])); + const pcNeeds = controlGraph.get('publish-crates') ?? new Set(); + pcNeeds.add('publish-testpypi'); + controlGraph.set('publish-crates', pcNeeds); + assert.ok( + transitivelyNeeds(controlGraph, 'publish-crates', 'publish-testpypi'), + 'positive control: graph with publish-crates → publish-testpypi edge must report transitive (PF-013)', + ); + + assert.ok( + !transitivelyNeeds(graph, 'publish-crates', 'publish-testpypi'), + 'publish-crates must NOT transitively need publish-testpypi — the opt-in TestPyPI ' + + 'upload must never block a tag release (ADR-013)', + ); + }); + + // ------------------------------------------------------------------------- + // M10c: ADR-013 three-place rule — for every job id, a job-level if: + // containing "refs/tags/v" or "inputs." implies its display name: is in + // TIER_B_EXPECTED_SKIPPED, and every member of TIER_B_EXPECTED_SKIPPED is + // such a guarded job. Set-equality with size 5. + // + // Positive control: a fake job section with a guarded if: and a name not in + // the set must be flagged. + // ------------------------------------------------------------------------- + test('M10c: ADR-013 three-place rule — guarded jobs and TIER_B_EXPECTED_SKIPPED are the same set (size 5)', () => { + const allIds = findAllJobIds(yml); + + // Helper: extract job name field from a section. + const jobNameOf = (text) => { + const section = stripCommentLines(text); + const m = /^ name:\s+(.+)$/m.exec(section); + return m ? m[1].trim() : null; + }; + + // Collect guarded job names: jobs whose if: contains refs/tags/v or inputs. + const guardedNames = new Set(); + for (const id of allIds) { + const section = extractJobSection(yml, id) ?? ''; + const jobIf = extractJobIf(section); + if (jobIf && (jobIf.includes('refs/tags/v') || jobIf.includes('inputs.'))) { + const name = jobNameOf(section); + if (name) guardedNames.add(name); + } + } + + // Positive control (PF-013): a fake section with guarded if: and unlisted + // name must be detected as missing from TIER_B_EXPECTED_SKIPPED. + const fakeSection = [ + ' fake-publish-somewhere:', + " if: ${{ startsWith(github.ref, 'refs/tags/v') }}", + ' name: Publish to Somewhere Unlisted', + ' runs-on: ubuntu-latest', + ].join('\n'); + const fakeIf = extractJobIf(fakeSection); + const fakeName = jobNameOf(fakeSection); + assert.ok(fakeIf && fakeIf.includes('refs/tags/v'), 'positive control: extractJobIf must find the guarded if:'); + assert.ok(fakeName === 'Publish to Somewhere Unlisted', 'positive control: jobNameOf must read the name'); + assert.ok(!TIER_B_EXPECTED_SKIPPED.has(fakeName), 'positive control: unlisted name must not be in TIER_B_EXPECTED_SKIPPED'); + + // TIER_B_EXPECTED_SKIPPED must equal the guarded-jobs set exactly. + for (const name of guardedNames) { + assert.ok( + TIER_B_EXPECTED_SKIPPED.has(name), + `job with guarded if: (refs/tags/v or inputs.) named "${name}" is not in ` + + `TIER_B_EXPECTED_SKIPPED — a skipped run for this job would fail the Tier B verifier ` + + `on a dry-run or dispatch run (ADR-013 three-place rule)`, + ); + } + for (const name of TIER_B_EXPECTED_SKIPPED) { + assert.ok( + guardedNames.has(name), + `TIER_B_EXPECTED_SKIPPED contains "${name}" but no job in release.yml has a guarded ` + + `if: (refs/tags/v or inputs.) and that display name — the set has drifted (ADR-013)`, + ); + } + assert.equal( + guardedNames.size, 5, + `expected exactly 5 guarded jobs (ADR-013 three-place rule); found ${guardedNames.size}: ` + + `[${[...guardedNames].join(', ')}]`, + ); + }); + + // ------------------------------------------------------------------------- + // S19: no run: block in release.yml may contain the empty expression ${{ }} + // (dollar-brace-brace-whitespace*-brace-brace). GitHub's expression + // preprocessor scans run: block text WITHOUT stripping shell comments, and + // the empty expression is a parse error that makes GitHub reject the entire + // workflow with zero jobs emitted (run 34061583304 confirmed). + // + // Positive control: a planted string containing the pattern must be flagged. + // ------------------------------------------------------------------------- + test('S19: no run: block in release.yml contains the empty GitHub expression ${{ }} (parse rejection guard)', () => { + const EMPTY_EXPR = /\$\{\{\s*\}\}/; + + // Positive control (PF-013): the regex must match a planted occurrence. + const planted = 'echo "Actions only interpolates ${{ }}, not bare {{ }}"'; + assert.ok( + EMPTY_EXPR.test(planted), + 'positive control: the empty-expression regex must match the planted string; ' + + 'if this fails, the guard is broken', + ); + + // Count occurrences in the real file. + const matches = yml.match(new RegExp(EMPTY_EXPR.source, 'g')) ?? []; + assert.equal( + matches.length, 0, + `release.yml contains ${matches.length} occurrence(s) of the empty expression ` + + '\\$\\{\\{\\s*\\}\\} — GitHub\'s parser rejects this even inside shell comments ' + + `within run: blocks (run 34061583304, P-fix). Found at: ` + + matches.map((_, i) => { + const idx = yml.indexOf(matches[i] ?? ''); + const lineNum = yml.slice(0, idx).split('\n').length; + return `line ~${lineNum}`; + }).join(', '), + ); + }); + }); diff --git a/scripts/__test__/verify-pr-checks.spec.mjs b/scripts/__test__/verify-pr-checks.spec.mjs index f5e2311f..ff9a2c47 100644 --- a/scripts/__test__/verify-pr-checks.spec.mjs +++ b/scripts/__test__/verify-pr-checks.spec.mjs @@ -650,7 +650,7 @@ describe('AC-26 AC-28 AC-29: live path exit codes (injected runner)', () => { // CPU cost and any unexpected loops — network latency is zero. assert.ok(elapsed < 15000, `verifier must complete in < 15 s wall-clock (AC-30 clause b); took ${elapsed}ms`); - // D-PR6: adds one PR-files call (pulls/{n}/files?per_page=100) for the release-surface check. + // D-PR7: adds one PR-files call (pulls/{n}/files?per_page=100) for the release-surface check. // The /pulls/ stub also matches the files URL and returns PR_OK (no .files → empty list → ok). assert.equal(calls.length, 5, `expected 5 API calls (pr, protection, checks, status, files); got ${calls.length}`); const checkCall = calls.find(u => u.includes('/check-runs')); @@ -1443,9 +1443,9 @@ describe('TIER_B_EXPECTED_SKIPPED: release dry-run skipped publish jobs', () => ]; } - test('D-PR5a: all four publish names skipped + required green → PASS and merge command printed', () => { - // The RELEASING.md dry-run dispatched on a PR branch sees the four publish - // jobs as skipped (their refs/tags/v guard fires). The verifier must exit 0 + test('D-PR5a: all five publish names skipped + required green → PASS and merge command printed', () => { + // The RELEASING.md dry-run dispatched on a PR branch sees the five publish + // jobs as skipped (their refs/tags/v or inputs. guard fires). The verifier must exit 0 // so the operator can proceed to tag. const skippedPublishRuns = [...TIER_B_EXPECTED_SKIPPED].map(name => ({ name, @@ -1461,7 +1461,7 @@ describe('TIER_B_EXPECTED_SKIPPED: release dry-run skipped publish jobs', () => prNumber: 338, }); assert.equal(result.exitCode, 0, - `four publish names skipped must not block PASS; lines:\n${result.lines.join('\n')}`); + `five publish names skipped must not block PASS; lines:\n${result.lines.join('\n')}`); assert.ok(result.pass, 'must return pass=true'); assert.ok(result.mergeCommand, 'PASS must produce a merge command'); // Informational lines must be present (one per skipped job) @@ -1518,7 +1518,7 @@ describe('TIER_B_EXPECTED_SKIPPED: release dry-run skipped publish jobs', () => }); test('D-PR5d: an unrelated Tier B name with conclusion=skipped → FAIL (whitelist is exact)', () => { - // The allowance is exactly the four publish job names. Any other job name + // The allowance is exactly the five publish job names. Any other job name // that reports skipped must still fail Tier B (security-13: whitelist). const runs = basePassingRunsWith([ { name: 'Some other job', status: 'completed', conclusion: 'skipped' }, @@ -1582,7 +1582,7 @@ describe('TIER_B_EXPECTED_SKIPPED: release dry-run skipped publish jobs', () => }); // --------------------------------------------------------------------------- -// D-PR6: Release-surface presence check +// D-PR7: Release-surface presence check // // When a PR touches .github/workflows/release.yml, .github/actions/**, // crates/mds-napi/**, crates/mds-python/**, or scripts/verify-napi-names.mjs, @@ -1590,7 +1590,7 @@ describe('TIER_B_EXPECTED_SKIPPED: release dry-run skipped publish jobs', () => // job: "Version gate", "Stage + verify platform packages", // "Rehearse PyPI publish (no upload)". // --------------------------------------------------------------------------- -describe('D-PR6: release-surface presence check', () => { +describe('D-PR7: release-surface presence check', () => { // Helper: all required + expected passes; inject extras. function passingRunsWith(extras) { @@ -1610,7 +1610,7 @@ describe('D-PR6: release-surface presence check', () => { })); } - test('D-PR6a: changedFiles touches release surface + all RELEASE_SURFACE_CONTEXTS present/success → exit 0 and "release surface touched" line', () => { + test('D-PR7a: changedFiles touches release surface + all RELEASE_SURFACE_CONTEXTS present/success → exit 0 and "release surface touched" line', () => { const checkRuns = passingRunsWith(releaseSuccessRuns()); const result = evaluateChecks({ requiredContexts: REQUIRED, @@ -1628,7 +1628,7 @@ describe('D-PR6: release-surface presence check', () => { ); }); - test('D-PR6b: touched + "Version gate" absent → exit 1 naming it (#342)', () => { + test('D-PR7b: touched + "Version gate" absent → exit 1 naming it (#342)', () => { const withoutVersionGate = releaseSuccessRuns().filter(r => r.name !== 'Version gate'); const checkRuns = passingRunsWith(withoutVersionGate); const result = evaluateChecks({ @@ -1644,7 +1644,7 @@ describe('D-PR6: release-surface presence check', () => { assert.ok(allLines.includes('Version gate'), `must name the absent job; got:\n${allLines}`); }); - test('D-PR6c: touched + "Rehearse PyPI publish (no upload)" completed+skipped → exit 1', () => { + test('D-PR7c: touched + "Rehearse PyPI publish (no upload)" completed+skipped → exit 1', () => { const withSkippedRehearse = releaseSuccessRuns().map(r => r.name === 'Rehearse PyPI publish (no upload)' ? { ...r, conclusion: 'skipped' } @@ -1667,7 +1667,7 @@ describe('D-PR6: release-surface presence check', () => { ); }); - test('D-PR6d: touched + one present run in_progress → exit 1', () => { + test('D-PR7d: touched + one present run in_progress → exit 1', () => { const withInProgress = releaseSuccessRuns().map(r => r.name === 'Stage + verify platform packages' ? { name: r.name, status: 'in_progress', conclusion: null } @@ -1684,7 +1684,7 @@ describe('D-PR6: release-surface presence check', () => { assert.equal(result.exitCode, 1, 'in_progress release job with touched surface must exit 1'); }); - test('D-PR6e: changedFiles = ["crates/mds-core/src/lib.rs"], no release runs → exit 0 and "not touched" line', () => { + test('D-PR7e: changedFiles = ["crates/mds-core/src/lib.rs"], no release runs → exit 0 and "not touched" line', () => { const checkRuns = passingRunsWith([]); const result = evaluateChecks({ requiredContexts: REQUIRED, @@ -1702,7 +1702,7 @@ describe('D-PR6: release-surface presence check', () => { ); }); - test('D-PR6f: matchesReleaseSurface positives and negatives', () => { + test('D-PR7f: matchesReleaseSurface positives and negatives', () => { // Positives (must return true) assert.ok(matchesReleaseSurface('.github/actions/setup-wasm/action.yml'), '.github/actions/** pattern must match .github/actions/setup-wasm/action.yml'); @@ -1724,11 +1724,11 @@ describe('D-PR6: release-surface presence check', () => { 'crates/mds-core/** must NOT match (not in RELEASE_SURFACE)'); }); - test('D-PR6g: changedFiles undefined → exit 0 with skip notice (caller-path only, never an API failure)', () => { + test('D-PR7g: changedFiles undefined → exit 0 with skip notice (caller-path only, never an API failure)', () => { // evaluateChecks is an exported pure function; a caller holding only a SHA // cannot enumerate changed files, so `undefined` skips the check with a // notice. main() NEVER reaches this path: fetchChangedFiles fails closed on - // an API error (see D-PR6h) rather than degrading to undefined, because a + // an API error (see D-PR7h) rather than degrading to undefined, because a // transient 500 must not turn a release-surface PR into one that needs no // release check-runs (fail-open in a merge gate). const checkRuns = passingRunsWith([]); @@ -1749,14 +1749,14 @@ describe('D-PR6: release-surface presence check', () => { }); // ------------------------------------------------------------------------- - // D-PR6h: the live path must FAIL CLOSED when the files endpoint errors. + // D-PR7h: the live path must FAIL CLOSED when the files endpoint errors. // - // This is the fail-open that would otherwise sit under D-PR6: a 500 from + // This is the fail-open that would otherwise sit under D-PR7: a 500 from // /pulls/N/files would let a PR that touches release.yml pass the verifier // without a single release check-run. Indeterminate is exit 2 — the same // contract fetchCheckRuns, fetchStatuses and fetchRequiredContexts hold. // ------------------------------------------------------------------------- - test('D-PR6h: PR-files API error → exit 2 (fail closed, never a silent skip)', () => { + test('D-PR7h: PR-files API error → exit 2 (fail closed, never a silent skip)', () => { // Route the files URL to an error BEFORE the generic /pulls/ route, which // would otherwise answer it with PR metadata. const runner = stubRunner([ @@ -1773,7 +1773,7 @@ describe('D-PR6: release-surface presence check', () => { ); }); - test('D-PR6h: changed_files count mismatch → exit 2 (partial file list)', () => { + test('D-PR7h: changed_files count mismatch → exit 2 (partial file list)', () => { // The PR declares 3 changed files but the endpoint returns 1. Evaluating // the release-surface question on a partial list could miss the one file // that touches the surface (non-vacuity, avoids PF-013). @@ -1788,11 +1788,11 @@ describe('D-PR6: release-surface presence check', () => { 'a partial changed-files list must exit 2'); }); - test('D-PR6h: live path requires the release runs when the files endpoint reports a surface file', () => { + test('D-PR7h: live path requires the release runs when the files endpoint reports a surface file', () => { // End-to-end through main(): the files endpoint (not a hand-built // changedFiles array) drives the presence check. Without the release // check-runs this PR must FAIL — proving fetchChangedFiles is wired into - // evaluateChecks and that D-PR6a-f are not testing a disconnected function. + // evaluateChecks and that D-PR7a-f are not testing a disconnected function. const runner = stubRunner([ ['/files', [{ filename: 'crates/mds-napi/src/lib.rs' }]], ['/pulls/', { ...PR_OK, changed_files: 1 }], @@ -1804,7 +1804,7 @@ describe('D-PR6: release-surface presence check', () => { 'a release-surface file with no release check-runs must exit 1'); }); - test('D-PR6h: live path passes when the release runs are present for a surface file', () => { + test('D-PR7h: live path passes when the release runs are present for a surface file', () => { // The complement of the case above: same PR, plus the three release runs. // Without this pair, exit 1 could be coming from anywhere. const withRelease = { @@ -1829,14 +1829,14 @@ describe('D-PR6: release-surface presence check', () => { }); // ------------------------------------------------------------------------- - // D-PR6j: duplicate names resolve ALL-MUST-PASS, not newest-wins. + // D-PR7j: duplicate names resolve ALL-MUST-PASS, not newest-wins. // // Same contract as Tier A (see the checksByName comment in the production // file): `filter=latest` de-duplicates within one check-suite, but two suites // can publish the same name, so keeping only the last entry would let a green // re-run mask a red sibling — a fail-open in a merge gate. // ------------------------------------------------------------------------- - test('D-PR6j: a failed and a succeeded run sharing a release-surface name → exit 1 (all must pass)', () => { + test('D-PR7j: a failed and a succeeded run sharing a release-surface name → exit 1 (all must pass)', () => { const checkRuns = passingRunsWith([ ...releaseSuccessRuns(), // A second run under a name that already has a success above. @@ -1857,11 +1857,11 @@ describe('D-PR6: release-surface presence check', () => { }); // ------------------------------------------------------------------------- - // D-PR6i: matchesReleaseSurface prefix and exact-match boundaries. + // D-PR7i: matchesReleaseSurface prefix and exact-match boundaries. // A `dir/**` pattern must not match a sibling whose name merely starts with // the directory name, and an exact pattern must not prefix-match. // ------------------------------------------------------------------------- - test('D-PR6i: "/**" requires a trailing slash and exact patterns do not prefix-match', () => { + test('D-PR7i: "/**" requires a trailing slash and exact patterns do not prefix-match', () => { assert.ok(!matchesReleaseSurface('.github/actions-foo/x.yml'), '.github/actions/** must not match the sibling directory .github/actions-foo/'); assert.ok(!matchesReleaseSurface('crates/mds-napi-extra/src/lib.rs'), diff --git a/scripts/verify-pr-checks.mjs b/scripts/verify-pr-checks.mjs index bc6b94b6..d7f28878 100644 --- a/scripts/verify-pr-checks.mjs +++ b/scripts/verify-pr-checks.mjs @@ -59,6 +59,13 @@ * D-PR6: Exit codes — 0 PASS, 1 FAIL, 2 indeterminate. "Cannot tell" is * never 0. * + * D-PR7: Release-surface presence check — when a PR touches the release + * surface (paths matching release.yml's pull_request.paths filter), + * each RELEASE_SURFACE_CONTEXTS job must be completed+success. + * Absence or non-success is FAIL (Tier A semantics applied to release + * check-runs). The changed-file list is fetched via the PR-files API + * and fails closed on every indeterminate outcome (avoids PF-013). + * * Usage: * node scripts/verify-pr-checks.mjs * node scripts/verify-pr-checks.mjs --required-from @@ -143,7 +150,7 @@ export const TIER_B_EXPECTED_SKIPPED = new Set([ ]); // --------------------------------------------------------------------------- -// D-PR6: Release-surface presence check +// D-PR7: Release-surface presence check // // When a PR touches the release surface (paths matching release.yml's // pull_request.paths filter), the verifier REQUIRES a completed+success run @@ -290,7 +297,7 @@ function defaultGhRunner(args) { * headSha: string; * prNumber?: number; // included in the emitted merge command (D-PR5) * expectedContexts?: string[]; // defaults to EXPECTED_CONTEXTS - * changedFiles?: string[]; // D-PR6: when present, release-surface presence check runs + * changedFiles?: string[]; // D-PR7: when present, release-surface presence check runs * }} EvaluateInput * * @typedef {{ @@ -539,7 +546,7 @@ export function evaluateChecks({ } } - // ---- D-PR6: release-surface presence check ---- + // ---- D-PR7: release-surface presence check ---- // When changedFiles is provided and any file matches the release surface, // require each RELEASE_SURFACE_CONTEXTS job to be present and success. // Absence or non-success is FAIL (Tier A semantics applied to release runs). @@ -555,7 +562,7 @@ export function evaluateChecks({ // API error rather than degrading to undefined. if (changedFiles === undefined) { lines.push( - ' · D-PR6: changedFiles not provided — release-surface presence check skipped ' + + ' · D-PR7: changedFiles not provided — release-surface presence check skipped ' + '(caller would need a PR number to enumerate changed files)', ); } else { @@ -570,7 +577,7 @@ export function evaluateChecks({ const releaseRuns = checkRuns.filter(cr => cr.name === ctx); if (releaseRuns.length === 0) { failures.push( - `D-PR6 (release surface): "${ctx}" absent — the PR touches the release ` + + `D-PR7 (release surface): "${ctx}" absent — the PR touches the release ` + `surface but release.yml's pull_request run is missing, not finished, or failed (#342)`, ); pass = false; @@ -578,7 +585,7 @@ export function evaluateChecks({ for (const cr of releaseRuns) { if (cr.status !== 'completed' || cr.conclusion !== 'success') { failures.push( - `D-PR6 (release surface): "${ctx}" — status=${cr.status}, conclusion=${cr.conclusion ?? 'null'} ` + + `D-PR7 (release surface): "${ctx}" — status=${cr.status}, conclusion=${cr.conclusion ?? 'null'} ` + `— the PR touches the release surface but release.yml's pull_request run is missing, not finished, or failed (#342)`, ); pass = false; @@ -800,7 +807,7 @@ export function fetchRequiredContexts(baseBranch, requiredFrom, runner) { } /** - * Fetch changed-file paths for a PR, paginated (D-PR6). + * Fetch changed-file paths for a PR, paginated (D-PR7). * * Fails CLOSED on every indeterminate outcome, matching fetchCheckRuns, * fetchStatuses and fetchRequiredContexts: a transient API error must not let a @@ -834,7 +841,7 @@ export function fetchChangedFiles(prNumber, declaredCount, runner) { exitCode: 2, message: `PR files API error (page ${page}): ${data.stderr} — cannot determine whether ` + - `this PR touches the release surface, and "cannot tell" is not a pass (D-PR6)`, + `this PR touches the release surface, and "cannot tell" is not a pass (D-PR7)`, }; } const items = Array.isArray(data) ? data : (data.files ?? []); @@ -857,7 +864,7 @@ export function fetchChangedFiles(prNumber, declaredCount, runner) { exitCode: 2, message: `collected ${files.length} changed files but PR declares changed_files=${declaredCount} — ` + - `partial file list (D-PR6 non-vacuity, avoids PF-013)`, + `partial file list (D-PR7 non-vacuity, avoids PF-013)`, }; } @@ -965,7 +972,7 @@ export function main(argv = process.argv.slice(2), runner = defaultGhRunner, ghV return st.exitCode; } - // ---- D-PR6: fetch changed files for release-surface presence check ---- + // ---- D-PR7: fetch changed files for release-surface presence check ---- // Fails closed on every indeterminate outcome (API error, pagination // overflow, count mismatch), exactly like the three fetches above: the live // path always knows the file list or exits 2 (avoids PF-013). From 3a803f59ca3e79c67740061e91d217756889fbee Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 7 Sep 2026 01:37:30 +0300 Subject: [PATCH 5/6] docs(release): document the release-surface gate, PyPI rehearsal and TestPyPI leg; tidy two release.yml comments (#342, #350) --- .github/workflows/release.yml | 15 ++++---- RELEASING.md | 72 +++++++++++++++++++++++++++-------- 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f2f2d91..7106e8dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -136,11 +136,12 @@ jobs: - name: "Run positive-control and class-completeness suite" run: npm run test:gates # reliability-12: assert the SHA being released went green in CI before - # any irreversible publish starts. Runs on BOTH triggers - a tag push - # verifies the tagged commit, a workflow_dispatch dry run verifies the - # dispatched ref's HEAD - so the dry run now exercises this gate instead - # of skipping it. Cancelled, failed, in-progress and absent runs all fail - # closed (PF-017; PF-013: absence is not success). + # any irreversible publish starts. Runs on tag push (verifies the tagged + # commit) and workflow_dispatch (verifies the dispatched ref's HEAD). + # Step-skipped on pull_request: github.sha is the ephemeral merge commit, + # not the branch head — see the if: guard on this step and the sibling + # notice step below. Cancelled, failed, in-progress and absent runs all + # fail closed (PF-017; PF-013: absence is not success). # Cannot reuse scripts/verify-pr-checks.mjs here - that script requires a # PR number, and tag pushes reference a commit, not a PR. # Branch protection is NOT read: /branches/main/protection needs @@ -1216,8 +1217,6 @@ jobs: # HTTP 200) — but NEVER for an annotated tag object (a892a5a6 = 404, # the v0.4.1 failure). A 40-hex pin is indistinguishable by eye between # the two cases, which is why the policy requires a vX.Y.Z release tag. - # `rehearse-publish-python` enforces this as a gate on every PR, dry run - # and tag push (#350, PF-040). # Using the release tag name is unambiguous. rehearse-publish-python # proves this exact ref resolves to a pullable GHCR image (and that twine # accepts the distributions) before publish-crates makes crates.io @@ -1225,6 +1224,8 @@ jobs: # When bumping this pin: update PIN_REF in rehearse-publish-python AND # the uses: pin in publish-testpypi, together. Spec S16 fails the build # if they drift. + # `rehearse-publish-python` enforces this as a gate on every PR, dry run + # and tag push (#350, PF-040). uses: pypa/gh-action-pypi-publish@v1.14.2 with: packages-dir: python-dist/ diff --git a/RELEASING.md b/RELEASING.md index 66ea95af..2fc7f0db 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -75,10 +75,18 @@ These are **not** automated and must be done before the first release: skipped conclusion. The trusted publisher for TestPyPI is independent of the PyPI one — both must be configured separately. + To trigger the first upload and lock the TestPyPI name, run: + `gh workflow run release.yml --ref -f testpypi=true` + A boolean dispatch input cannot be set without `-f`; omitting it leaves `testpypi` + at its default (`false`) and `publish-testpypi` is skipped. + **Publisher expiry:** a PyPI pending publisher auto-expires ~30 days after creation unless an upload lands. For TestPyPI, the first `workflow_dispatch` - run with `testpypi: true` is what locks the name — it is the first upload. - Re-create the publisher if it has expired before that first dispatch. + run with `testpypi: true` is the first upload that locks the name. If the + pending publisher is missing or expired, the `Publish to TestPyPI (rehearsal)` + job fails at the OIDC token exchange with an `invalid-publisher` message — + re-file the pending publisher (environment name BLANK) and re-dispatch; no + code change needed. ## Pre-flight (before tagging) @@ -168,25 +176,51 @@ so the fail-closed behaviour is informational, not a merge blocker by itself. `@mdscript` scope. A read-only or wrongly-scoped token passes the probe but fails at publish time. +Even though release-surface PRs now trigger `release.yml` automatically, a +manual `gh workflow run release.yml --ref ` is still required in four +cases (the five release-surface paths are `.github/workflows/release.yml`, +`.github/actions/**`, `crates/mds-napi/**`, `crates/mds-python/**`, and +`scripts/verify-napi-names.mjs`): + +1. **CI-history gate (PF-017)** — the gate is step-skipped on `pull_request` + because `github.sha` is the ephemeral merge commit, not the branch head; a + `::notice::` makes the skip visible. It runs only on tag push and dispatch. +2. **Changes outside the release surface** — dependency sweeps, + `crates/mds-core/**`, `Cargo.toml`, and `package.json` are not in the five + paths above and do not trigger a `pull_request` run on `release.yml`. +3. **Dependabot and fork PRs** — no repository secrets and no `id-token: write`; + `Version gate` fails closed with the "No Actions secrets on this run" error. + Do not merge on those checks; supersede with a maintainer-authored PR or + dispatch by hand. +4. **TestPyPI handshake** — `gh workflow run release.yml --ref -f testpypi=true` + (once per version; `skip-existing: true` makes repeats no-ops). + The dry run also exercises the **CI-history gate** (PF-017), asserting a completed+success `CI` run for the dispatched ref's HEAD. Dispatch it only after -that ref's CI has finished, or the gate fails closed on a still-running run. +that ref's CI has finished, or the gate fails closed on a still-running run. The +gate is skipped on `pull_request` runs by a step-level guard (the sibling notice +step makes the skip visible) and is enforced unchanged on tag push and +`workflow_dispatch`. -The dry run also runs the **`rehearse-publish-python` job**, which rehearses -everything about `publish-python` except the one irreversible act. It runs four -gates, each with a positive control (PF-013 — a gate never observed rejecting -anything is not evidence): +### PyPI publish rehearsal (#350, PF-039) + +The dry run also runs the **`rehearse-publish-python` job** (`Rehearse PyPI +publish (no upload)`), which rehearses everything about `publish-python` except +the one irreversible act. It proves four properties, each with a positive +control (PF-013 — a gate never observed rejecting anything is not evidence): 1. **Pin shape** — the `pypa/gh-action-pypi-publish` pin must be a `vX.Y.Z` - release tag. Control: the v0.4.1 annotated-tag-object SHA must be rejected. -2. **GHCR manifest** — GHCR must hold an image for that exact ref. Control: a ref - that cannot exist must not return HTTP 200. -3. **`docker pull`** — the image the runner actually fetches must pull. Control: - pulling the impossible ref must fail. ("The ref resolves in git" is necessary - and never sufficient — PF-040.) -4. **`twine check`** — twine runs out of that image with `--network none` and - `--entrypoint twine`, so the step physically cannot reach pypi.org. Control: - a deliberately corrupt wheel must be rejected. + release tag. Control: the image-backed commit `dc37677b...` and the annotated + tag object `a892a5a6...` are both rejected — policy, not existence. +2. **GHCR manifest** — GHCR must hold an image for that exact ref (HTTP 200). + Control: a ref that cannot exist must return 404 with `MANIFEST_UNKNOWN`. +3. **`docker pull`** — the image must actually pull. Control: a missing tag must + fail; bounded to 3 attempts. ("The ref resolves in git" is necessary and + never sufficient — PF-040.) +4. **`twine check`** — the image's own twine 7.0.0 runs against all 8 + distributions with `--network none` and `--entrypoint twine`, so the step + physically cannot reach pypi.org. Control: a deliberately corrupt wheel must + be rejected. > The job **must never** `uses: pypa/gh-action-pypi-publish`. The action has no > dry-run/no-upload mode: an unrecognised `dry-run:` input is warned about and @@ -197,6 +231,12 @@ anything is not evidence): > invocation to `publish-python` and `publish-testpypi` only, and the rehearsal > is denied `id-token` so it holds no credential to upload with. +The rehearsal proves everything listed above; it cannot prove the upload handshake +and trusted-publisher exchange at publish time — the action has no dry-run mode. +The credential half is covered by `version-gate`'s OIDC probe (which runs on +every event including PRs); the upload half is closed by the opt-in TestPyPI leg +(`-f testpypi=true`), which exercises the full exchange once per version. + `publish-crates` needs this job, so a broken pin aborts the release before the irreversible crates.io write. It is intentionally unguarded, so it runs on `pull_request` and `workflow_dispatch`, not just tag pushes (PF-039). From e02bcf280dc50bb8df032744aa2a2520c02865ee Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 7 Sep 2026 01:59:15 +0300 Subject: [PATCH 6/6] ci(release): print the image's twine version in the PyPI rehearsal; reword a comment that mimicked a uses: line (#350) --- .github/workflows/release.yml | 8 ++++++-- scripts/__test__/release-auth-probe.spec.mjs | 8 ++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7106e8dc..4aa06d54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -730,8 +730,8 @@ jobs: contents: read env: # Single source of truth for the pin under test. Must equal the ref in the - # `uses: pypa/gh-action-pypi-publish@` lines in publish-python and - # publish-testpypi — spec S16 pins that equality, because a rehearsal that + # two publish steps that invoke pypa/gh-action-pypi-publish (publish-python and + # publish-testpypi) — spec S16 pins that equality, because a rehearsal that # validates a different ref than the release pulls proves nothing (PF-040). PIN_REF: v1.14.2 PUBLISH_IMAGE: ghcr.io/pypa/gh-action-pypi-publish @@ -886,6 +886,10 @@ jobs: --entrypoint twine "${PUBLISH_IMAGE}:${PIN_REF}" check "$@" } + echo "image twine:" + docker run --rm --network none \ + --entrypoint twine "${PUBLISH_IMAGE}:${PIN_REF}" --version + # Positive control (PF-013): a corrupt wheel must be REJECTED. A green # `twine check` over the real distributions says nothing until the # command is observed failing on something. diff --git a/scripts/__test__/release-auth-probe.spec.mjs b/scripts/__test__/release-auth-probe.spec.mjs index 295881d7..5502a6d6 100644 --- a/scripts/__test__/release-auth-probe.spec.mjs +++ b/scripts/__test__/release-auth-probe.spec.mjs @@ -765,6 +765,14 @@ describe('B1: release-surface PR gate and rehearsal jobs', () => { 'the GHCR and docker-pull gates must be exercised against a ref that cannot exist, ' + 'so a probe that returns 200 for everything is caught (PF-013)', ); + + // Gate 4: the rehearsal must print the twine version from the publish image + // so logs capture which twine validated the distributions (plan spec item). + assert.ok( + section.includes('--version'), + 'Gate 4: rehearsal must invoke the image twine with --version (--entrypoint twine ' + + '... --version) so the log captures which twine version validated the distributions', + ); }); // -------------------------------------------------------------------------