diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e118743a..4aa06d54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,35 @@ 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) +# +# 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: - "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 @@ -45,7 +67,17 @@ jobs: 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. @@ -87,6 +119,10 @@ 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" + # 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. @@ -100,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 @@ -112,6 +149,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 +230,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 +690,272 @@ 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 publish path before + # any irreversible crates.io publish (PF-039: tag-guarded steps unexercised). + # =========================================================================== + + # --------------------------------------------------------------------------- + # 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: [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 + # 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 + # 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 + # 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 + with: + pattern: python-* + path: python-dist/ + merge-multiple: 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): 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 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}" + - 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 --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 + # 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_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): 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: missing manifests are detected." + + 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 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. + 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 + + # 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: | + 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 "$@" + } + + 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. + 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. + # 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: [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 + contents: read + steps: + - name: Download Python artifacts + uses: actions/download-artifact@v8 + with: + 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). + 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 +973,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 @@ -874,8 +1182,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 @@ -903,20 +1211,25 @@ 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 - # 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". 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). + # `@`, 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. + # 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. + # `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/CHANGELOG.md b/CHANGELOG.md index 3186996d..b885ef47 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 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 67e85d81..2fc7f0db 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -57,7 +57,36 @@ 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 + 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/): + - 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. + + 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 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) @@ -125,7 +154,8 @@ 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 publish-python + # step. Publishes NOTHING. ``` The dry-run workflow runs `version-gate` in full, which now includes the @@ -133,17 +163,89 @@ The dry-run workflow runs `version-gate` in full, which now includes the 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 -crates.io release. +therefore fails the dry run — all before any irreversible crates.io release. + +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 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`. + +### 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 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 +> 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. + +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). + +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)`. 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 @@ -151,6 +253,27 @@ 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. +### Release-surface PRs + +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, 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 ### Tag-push (the only path) @@ -187,23 +310,30 @@ 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, 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. **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** — 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`. - 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. + `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 3e17ae44..5502a6d6 100644 --- a/scripts/__test__/release-auth-probe.spec.mjs +++ b/scripts/__test__/release-auth-probe.spec.mjs @@ -23,6 +23,11 @@ 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, + RELEASE_SURFACE_CONTEXTS, + 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 +231,790 @@ 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; +} + +/** + * 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) +// --------------------------------------------------------------------------- + +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 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 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.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}`, + ); + }); + + // ------------------------------------------------------------------------- + // 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: 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: 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'), + '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)}`, + ); + }); + + // ------------------------------------------------------------------------- + // 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.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( + onBlock.includes('pull_request:'), + 'release.yml must have a pull_request: trigger so release-surface PRs are validated; ' + + `got on: block:\n${onBlock}`, + ); + + const paths = extractPullRequestPaths(yml); + assert.ok( + 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)}`, + ); + }); + + // ------------------------------------------------------------------------- + // 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). + 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}`, + ); + + 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(', ')}]`, + ); + }); + + // ------------------------------------------------------------------------- + // 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.`, + ); + } + + // 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+$/)`, + ); + }); + + // ------------------------------------------------------------------------- + // 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)', + ); + + // 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', + ); + }); + + // ------------------------------------------------------------------------- + // 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(' | ')}]`, + ); + } + }); + + // ------------------------------------------------------------------------- + // 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 41c96e3c..ff9a2c47 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-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')); 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', () => { @@ -1437,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, @@ -1455,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) @@ -1512,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' }, @@ -1553,6 +1559,323 @@ 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-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, +// 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-PR7: 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-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, + 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-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({ + 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-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' } + : 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-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 } + : 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-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, + 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-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'); + 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-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-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([]); + 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}`, + ); + }); + + // ------------------------------------------------------------------------- + // D-PR7h: the live path must FAIL CLOSED when the files endpoint errors. + // + // 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-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([ + ['/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-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). + 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-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-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 }], + ['/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-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 = { + ...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-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-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. + { 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-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-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'), + '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 15733d04..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 @@ -124,19 +131,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-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 +// 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 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 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). + */ +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 +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-PR7: when present, release-surface presence check runs * }} EvaluateInput * * @typedef {{ @@ -253,6 +325,7 @@ export function evaluateChecks({ headSha, prNumber, expectedContexts = EXPECTED_CONTEXTS, + changedFiles, }) { const lines = []; const failures = []; @@ -473,6 +546,58 @@ export function evaluateChecks({ } } + // ---- 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). + // + // 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-PR7: 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-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; + } else { + for (const cr of releaseRuns) { + if (cr.status !== 'completed' || cr.conclusion !== 'success') { + failures.push( + `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; + } + } + } + } + } else { + lines.push(' release surface not touched — no release run required'); + } + } + // ---- Compose result ---- for (const f of failures) { lines.push(`✖ ${f}`); @@ -681,6 +806,71 @@ export function fetchRequiredContexts(baseBranch, requiredFrom, runner) { return { ok: true, contexts, resolvedBranch: branch, notes }; } +/** + * 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 + * 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 + * @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) { + // 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-PR7)`, + }; + } + 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-PR7 non-vacuity, avoids PF-013)`, + }; + } + + return { ok: true, files }; +} + const USAGE = 'Usage: node scripts/verify-pr-checks.mjs [--required-from ]'; @@ -782,6 +972,18 @@ export function main(argv = process.argv.slice(2), runner = defaultGhRunner, ghV return st.exitCode; } + // ---- 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). + const filesResult = fetchChangedFiles(prNumber, prData.changed_files ?? null, runner); + if (!filesResult.ok) { + fail(filesResult.message); + return filesResult.exitCode; + } + const changedFiles = filesResult.files; + console.log(` changed files: ${changedFiles.length}`); + // ---- Evaluate (D-PR1: pure function) ---- const result = evaluateChecks({ requiredContexts: req.contexts, @@ -789,6 +991,7 @@ export function main(argv = process.argv.slice(2), runner = defaultGhRunner, ghV statuses: st.statuses, headSha, prNumber, + changedFiles, }); for (const line of result.lines) {