diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml index 921df69..2e07815 100644 --- a/.github/workflows/verify.yaml +++ b/.github/workflows/verify.yaml @@ -37,6 +37,20 @@ jobs: # toolchain, so it runs first and fails in seconds. - name: workflow supply-chain pins (AUD-S09 — SEC-04) run: bash hack/lint/workflow_pins_test.sh + - name: dogfood + -count=1 wiring pin (EX-S08 — REQ-EX-S08-02/03) + # RELSE-08, following the AUD2-S05 precedent below. This gate otherwise + # reaches CI only through `task check` -> release-exitgate, whose + # `if: github.event_name != 'pull_request'` means a PR that strips + # `-count=1` from THIS FILE would merge green and redden main + # afterwards — the gate's own thesis failing to apply to the gate. + # Pure text over Taskfile.yml + this workflow, no toolchain, so it + # belongs here beside workflow_pins_test.sh and before setup-go. + # Keep it argument-free and undisarmed: the script asserts its own step + # (section 7) is present, unargumented and free of + # `if:`/`continue-on-error:`, so weakening this reddens it. These + # comment lines live INSIDE the step on purpose, as the AUD2 step + # documents. + run: bash hack/examples/dogfood_wiring_test.sh - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: stable # go.mod stays the minimum; CI runs latest stable (govulncheck needs it) @@ -99,7 +113,22 @@ jobs: - name: dogfood examples (EX-S08 — shared discovery script; every examples/packs/ with .assent/tests gates itself green under the real `assent test` CLI) run: bash hack/dogfood-examples.sh - name: comparison corpus dogfood (PCS-S08 — examples/comparison validates and runs green) - run: go test ./examples/comparison/... + # -count=1 IS THE GATE, exactly as in Taskfile.yml's dogfood-comparison. + # examples/comparison is an EXTERNAL test package referencing only + # exported constants from internal/compare, so the compare logic is + # unreachable from the test binary the Go cache keys on (the linker + # strips it) and the real logic runs in a `go build -o … ./cmd/assent` + # subprocess outside the cache key. actions/setup-go restores GOCACHE + # across commits, so without this flag this step reports `ok … (cached)` + # and contributes NOTHING to the PR: not the JSON-schema validation of + # the suite docs, not the corpusGateCoverage gate-ID map, not + # TestCompareCorpusGateFailurePath. (The corpus OUTCOME stays defended + # on PRs by hack/compare/exitgate_test.sh, which drives the built binary + # through bash with no Go test cache in the path.) Fix it HERE, per + # invocation — do NOT "fix" it by setting `cache: false` on setup-go: + # that would slow every job in every workflow to close one blind + # invocation. Pinned by hack/examples/dogfood_wiring_test.sh. + run: go test -count=1 ./examples/comparison/... - name: PCS compare exit gate (RELSE-03 — REQ-PCS-S09) run: bash hack/compare/exitgate_test.sh # AUD-S02 (REQ-AUD-S02-02) — CHANGELOG.md drift gate, main + schedule only. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0af63bd..d70c8ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,10 @@ repository still gets a decision, never by following the link; no release carrie - :bug: fix(audit): measure the REL-03 guard's terminal path, not "a return appears" - :bug: fix(audit): assert no fall-through, not where the return sits - :bug: fix(audit): require an ALL-of FAIL quorum and refuse bash < 4.3 +- :bug: fix(taskfile): -count=1 on the cache-blind dogfood-comparison and e2e gates +- :bug: fix(ci): -count=1 on verify.yaml's comparison corpus dogfood +- :bug: fix(ci): run the -count=1 wiring pin in the PR-visible verify job +- :bug: fix(ci): pin COUNT1-F01 and refuse a merged gate step ### Refactoring - :recycle: refactor(audit): pin REL-03 and REL-07 by test name, demote source shape diff --git a/Taskfile.yml b/Taskfile.yml index 6c3ac41..eee0cb0 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -66,7 +66,22 @@ tasks: dogfood-comparison: desc: "Dogfood gate (PCS-S08): comparison corpus validates and runs green under assent compare --suite" cmds: - - go test ./examples/comparison/... + # -count=1 IS THE GATE. Without it this stage reports `ok … (cached)` and + # exits 0 on a tree where the same test genuinely fails. The Go test cache + # keys on the test BINARY's content ID, and examples/comparison is an + # EXTERNAL test package that references only exported constants from + # internal/compare (GateZeroMissedDestructive, ExitCodeForGate) — the + # comparison logic itself is unreachable from that binary and the linker + # strips it, so the content ID does not move when internal/compare + # changes. The real logic runs in a subprocess the test builds at runtime + # (`go build -o … ./cmd/assent`), entirely outside the cache key. Measured + # 2026-08-19: deleting `|| e == aggregate.EffectChallenge` from + # isStricterInterventionEffect left this stage green-and-cached while + # `-count=1` on the identical tree failed with exit status 6. Corpus DATA + # edits do invalidate (copyDir os.ReadFile's them, so testlog records + # them); production code changes do not. Pinned by + # hack/examples/dogfood_wiring_test.sh. + - go test -count=1 ./examples/comparison/... check: desc: "Gate: fmt + vet + lint + test + build (must pass before every commit)" @@ -192,7 +207,15 @@ tasks: e2e: desc: E2E tests against a real GitLab (build tag e2e) — see test/e2e/README.md cmds: - - go test -tags e2e ./test/e2e/... + # -count=1 for the same reason as dogfood-comparison: this suite's subject + # is a PREBUILT bin/assent (test/e2e/skeleton_test.go) driven against a + # live GitLab endpoint named by ASSENT_E2E_GITLAB. Neither the binary's + # bytes nor the forge's state is in the test cache key, so a cached PASS + # would assert nothing about the run the operator just asked for. Costs + # nothing: this task is never part of `task check` and the suite skips + # unless the endpoint env var is set. Pinned by + # hack/examples/dogfood_wiring_test.sh. + - go test -count=1 -tags e2e ./test/e2e/... docs: desc: Serve the docs site locally diff --git a/hack/examples/dogfood_wiring_test.sh b/hack/examples/dogfood_wiring_test.sh index b081b8e..0b84a97 100755 --- a/hack/examples/dogfood_wiring_test.sh +++ b/hack/examples/dogfood_wiring_test.sh @@ -7,6 +7,19 @@ # REQ-EX-S08-03: `task check` runs dogfood-examples (after build); deleting # that line from check: must redden this pin. # +# Section 5 extends the same discipline to a SECOND way a dogfood gate can be +# invoked-by-nothing: `task dogfood-comparison` runs `go test` over an EXTERNAL +# test package whose real subject is a binary the test builds at runtime, so the +# Go test cache key is blind to production changes and the stage can print +# `ok … (cached)` and exit 0 on a tree where the test genuinely fails (measured +# 2026-08-19). `-count=1` is what makes that stage a gate at all — so it is +# pinned here, with a mutation control that strips the flag from the COMMAND +# LINE while leaving the explanatory comment in place, which is exactly how a +# human would regress it. Section 6 pins the SAME flag on verify.yaml's copy of +# that invocation: actions/setup-go restores GOCACHE across commits, so the CI +# half is blind in exactly the same way, and it is the half where a vacuous PASS +# turns a red tree into a green PR. +# # Follows the hack/release/changelog_gate_test.sh / example_format_inventory_test.sh # discipline: every "is it wired" assertion is re-run against a mutated copy # with the wiring deleted, so the assertion is proven capable of failing @@ -131,4 +144,349 @@ echo "== 4. hack/dogfood-examples.sh exists and is executable ==" [[ -x "$ROOT/$SCRIPT" ]] || fail "$SCRIPT is not executable" echo "OK: $SCRIPT present and executable" -echo "PASS: dogfood wiring (REQ-EX-S08-02, REQ-EX-S08-03) — task check runs dogfood-examples after build; Taskfile.yml and verify.yaml both delegate to the shared discovery script" +# --------------------------------- 5. -count=1 on cache-blind `go test` gates -- +# +# COUNT1_TASKS: Taskfile tasks whose `go test` subject is NOT the test binary the +# cache keys on — it is a binary built (or prebuilt) at runtime and driven as a +# subprocess. For those the cache key is blind to the code under test, so a +# cached PASS is vacuous and `-count=1` is load-bearing: +# +# dogfood-comparison examples/comparison/validate_test.go is an EXTERNAL test +# package referencing only exported constants from +# internal/compare; the compare logic is unreachable from +# the test binary (linker strips it) and runs instead in a +# `go build -o … ./cmd/assent` subprocess. +# e2e test/e2e drives a PREBUILT bin/assent against a live +# GitLab endpoint; neither the binary bytes nor the forge +# state is in the cache key. +# +# Deliberately NOT listed, with the true reason rather than a tidy one: +# +# test whole-tree `go test -race ./...`. Its subjects are OVERWHELMINGLY +# in-process, the cache is honest for them, and it is a real time +# saver — but not uniformly: internal/schemadrift compares local +# schemas/ (read in-process, so testlog records it) against +# `git show origin/main:…` run as a SUBPROCESS (invisible), and +# internal/provider/isolation_test.go `go build`s a fixture at +# runtime. Tracked as backlog row COUNT1-F01 +# (openspec/specs/backlog.md, "Found in flight" block), NOT fixed +# here: isolating them means carving packages out of +# `test`/`coverage` and losing an otherwise-honest whole-tree +# cache. examples/comparison is also cached here under a separate +# (-race) entry; that is covered because dogfood-comparison re-runs +# the same package with -count=1 in the same `task check`. +# coverage NOT "in-process subjects" — `./internal/...` contains both +# packages the `test` bullet just named (COUNT1-F01 applies here +# verbatim; the same two blind spots, the same reason for leaving +# them). The verdict rests on something else entirely: the gated +# number is honest because a cached `-coverprofile` run REGENERATES +# the profile faithfully (measured: the cached run reprints the +# identical total), so what this gate reads as evidence is never a +# stale number even when a cached package result is stale. +# +# `determinism` needs no pin: `-count=2` is uncacheable by construction. +COUNT1_TASKS=(dogfood-comparison e2e) + +# count1_pinned — the task's `go test` COMMAND LINE carries +# -count=1. Anchored on the `- go test` command shape and stopping at `#`, so a +# comment that merely mentions -count=1 can never satisfy it. +count1_pinned() { + extract_block "$1" "$2" \ + | grep -qE '^[[:space:]]*-[[:space:]]+go test[^#]*[[:space:]]-count=1([[:space:]]|$)' +} + +echo "== 5. -count=1 pins on cache-blind go test gates ==" +for t in "${COUNT1_TASKS[@]}"; do + extract_block "$TASKFILE" "$t" >"$WORK/def.$t" + [[ -s "$WORK/def.$t" ]] || fail "Taskfile task '$t' is missing or extracted EMPTY — the -count=1 assertion below would be vacuous" + grep -qE '^[[:space:]]*-[[:space:]]+go test' "$WORK/def.$t" \ + || fail "Taskfile task '$t' no longer runs a 'go test' command — re-derive whether it is still cache-blind before deleting this pin" + count1_pinned "$TASKFILE" "$t" \ + || fail "Taskfile task '$t' runs 'go test' WITHOUT -count=1 — its subject is a runtime-built/prebuilt binary the Go test cache key cannot see, so the stage can report 'ok … (cached)' and exit 0 on a tree where the test genuinely fails" + echo "OK: $t runs go test with -count=1" +done + +echo "== 5b. the -count=1 assertion is comment-blind (positive control) ==" +cat >"$WORK/comment-only.yml" <<'EOF' + dogfood-comparison: + cmds: + # -count=1 is required here, honest + - go test ./examples/comparison/... + + next-task: +EOF +if count1_pinned "$WORK/comment-only.yml" dogfood-comparison; then + fail "count1_pinned accepted a task whose -count=1 appears ONLY in a comment — the assertion is satisfiable by prose and therefore vacuous" +fi +echo "OK: a -count=1 that lives only in a comment does NOT satisfy the pin" + +echo "== 5c. the -count=1 assertion can fail (mutation: strip the flag, keep the comment) ==" +mutant_c1="$WORK/Taskfile.nocount1.yml" +sed -E 's/^([[:space:]]*-[[:space:]]+go test)[[:space:]]+-count=1/\1/' "$TASKFILE" >"$mutant_c1" +if cmp -s "$TASKFILE" "$mutant_c1"; then + fail "mutation did not land: $mutant_c1 is byte-identical to Taskfile.yml" +fi +grep -qE '^[[:space:]]*#.*-count=1' "$mutant_c1" \ + || fail "mutation is not the intended one: it removed the explanatory -count=1 COMMENTS too, so 5c would not prove comment-blindness under mutation" +for t in "${COUNT1_TASKS[@]}"; do + if grep -qE '^[[:space:]]*-[[:space:]]+go test[^#]*[[:space:]]-count=1' <(extract_block "$mutant_c1" "$t"); then + fail "mutation did not land for '$t': the command line still carries -count=1 in $mutant_c1" + fi + if count1_pinned "$mutant_c1" "$t"; then + fail "count1_pinned reports '$t' pinned in a Taskfile with the flag stripped from its command line — the assertion is vacuous" + fi +done +echo "OK: stripping -count=1 from the command lines (comments intact) turns the pin red for: ${COUNT1_TASKS[*]}" + +echo "== 5d. task check still runs dogfood-comparison (an unwired gate cannot be saved by -count=1) ==" +check_lists_task "$TASKFILE" dogfood-comparison \ + || fail "'task check' does not run 'dogfood-comparison' — the gate is defined but invoked by nothing" +mutant_dc="$WORK/Taskfile.no-comparison.yml" +grep -vE '^[[:space:]]+- task: dogfood-comparison$' "$TASKFILE" >"$mutant_dc" +[[ "$(wc -l <"$mutant_dc")" -lt "$(wc -l <"$TASKFILE")" ]] || fail "mutation did not land: $mutant_dc has the same line count as Taskfile.yml" +if check_lists_task "$mutant_dc" dogfood-comparison; then + fail "check_lists_task reports dogfood-comparison wired in a Taskfile with that line deleted — the assertion is vacuous" +fi +echo "OK: check runs dogfood-comparison, and deleting that line turns the assertion red" + +# ------------------------- 6. the CI half of the -count=1 pin (verify.yaml) -- +# +# Taskfile.yml is only the local half. verify.yaml runs its OWN +# `go test … ./examples/comparison/…`, and actions/setup-go restores the Go +# build cache (where test results live) across commits, so an uncounted CI +# invocation can serve `ok … (cached)` and turn a genuinely red tree into a +# green PR. Same discipline as section 5: match the COMMAND, never a comment. + +# workflow_comparison_go_test_lines — every line that RUNS `go test` over +# examples/comparison. Anchored on the command shape (optional `- `, optional +# `run: `) and stopping at `#`, so both `run:` steps and `run: |` block lines +# match while a YAML comment mentioning the command cannot. +workflow_comparison_go_test_lines() { + grep -nE '^[[:space:]]*(-[[:space:]]+)?(run:[[:space:]]*)?go test[^#]*\./examples/comparison' "$1" || true +} + +# workflow_count1_pinned — 0: every such command carries -count=1. +# 1: at least one does not. 2: NO such command exists (vacuity, not success). +workflow_count1_pinned() { + local line found=0 + while IFS= read -r line; do + [[ -n "$line" ]] || continue + found=1 + # Anchored on the COMMAND and stopping at `#`, exactly like count1_pinned: + # a whole-line comment cannot reach this function at all, and a TRAILING + # `# -count=1` on an uncounted command must not satisfy it either (review + # finding F2 — the whole-line grep this replaces accepted precisely that). + grep -qE '(-[[:space:]]+)?(run:[[:space:]]*)?go test[^#]*[[:space:]]-count=1([[:space:]]|$)' \ + <<<"$line" || return 1 + done < <(workflow_comparison_go_test_lines "$1") + ((found == 1)) || return 2 + return 0 +} + +echo "== 6. verify.yaml runs the comparison corpus with -count=1 ==" +rc=0; workflow_count1_pinned "$WORKFLOW" || rc=$? +case "$rc" in + 0) echo "OK: every 'go test … ./examples/comparison…' in verify.yaml carries -count=1" ;; + 2) fail "verify.yaml contains NO 'go test … ./examples/comparison…' command — either the CI dogfood step was deleted or it was respelled past this matcher; re-derive the pin rather than leaving it silently vacuous" ;; + *) fail "verify.yaml runs 'go test' over examples/comparison WITHOUT -count=1 — setup-go restores GOCACHE across commits, so that step can report 'ok … (cached)' and pass a PR on a tree where the corpus genuinely fails" ;; +esac + +echo "== 6b. the workflow assertion is comment-blind (positive control) ==" +cat >"$WORK/wf-comment-only.yaml" <<'EOF' + # -count=1 is required here, honest + - name: comparison corpus dogfood + run: go test ./examples/comparison/... +EOF +rc=0; workflow_count1_pinned "$WORK/wf-comment-only.yaml" || rc=$? +((rc != 0)) || fail "workflow_count1_pinned accepted a workflow whose -count=1 appears ONLY in a comment — the assertion is satisfiable by prose" +((rc == 1)) || fail "the comment-only workflow control failed for the WRONG reason (rc=$rc, want 1 = flag missing from a present command)" +echo "OK: a -count=1 that lives only in a whole-line YAML comment does NOT satisfy the workflow pin" + +echo "== 6b2. …and neither does a TRAILING comment on an uncounted command (F2) ==" +cat >"$WORK/wf-trailing-comment.yaml" <<'EOF' + - name: comparison corpus dogfood + run: go test ./examples/comparison/... # -count=1 +EOF +rc=0; workflow_count1_pinned "$WORK/wf-trailing-comment.yaml" || rc=$? +((rc != 0)) || fail "workflow_count1_pinned accepted 'go test ./examples/comparison/... # -count=1' — the flag is in a TRAILING COMMENT and the command in CI is genuinely uncounted (F2)" +((rc == 1)) || fail "the trailing-comment control failed for the WRONG reason (rc=$rc, want 1 = flag missing from a present command)" +# The counterpart: a real flag plus an unrelated trailing comment must still PASS, +# so the fix is comment-BLIND, not comment-HOSTILE. +cat >"$WORK/wf-flag-plus-comment.yaml" <<'EOF' + - name: comparison corpus dogfood + run: go test -count=1 ./examples/comparison/... # cache is blind here +EOF +rc=0; workflow_count1_pinned "$WORK/wf-flag-plus-comment.yaml" || rc=$? +((rc == 0)) || fail "workflow_count1_pinned REJECTED a genuinely counted command that carries an unrelated trailing comment (rc=$rc) — the matcher is comment-hostile, not comment-blind" +echo "OK: a trailing '# -count=1' does not satisfy the pin, while a real flag beside a trailing comment still does" + +echo "== 6c. the workflow assertion can fail (mutation: strip the flag, keep the comment) ==" +mutant_wf_c1="$WORK/verify.nocount1.yaml" +sed -E 's/^([[:space:]]*(-[[:space:]]+)?(run:[[:space:]]*)?go test)[[:space:]]+-count=1/\1/' "$WORKFLOW" >"$mutant_wf_c1" +if cmp -s "$WORKFLOW" "$mutant_wf_c1"; then + fail "mutation did not land: $mutant_wf_c1 is byte-identical to verify.yaml" +fi +grep -qE '^[[:space:]]*#.*-count=1' "$mutant_wf_c1" \ + || fail "mutation is not the intended one: it stripped the explanatory -count=1 COMMENTS too, so 6c would not prove comment-blindness under mutation" +[[ -n "$(workflow_comparison_go_test_lines "$mutant_wf_c1")" ]] \ + || fail "mutation removed the comparison command entirely — the mutant would go red for vacuity (rc=2), not for the missing flag" +rc=0; workflow_count1_pinned "$mutant_wf_c1" || rc=$? +((rc != 0)) || fail "workflow_count1_pinned reports verify.yaml pinned with the flag stripped from its command line — the assertion is vacuous" +((rc == 1)) || fail "the verify.yaml mutant went red for the WRONG reason (rc=$rc, want 1 = flag missing from a present command)" +echo "OK: stripping -count=1 from verify.yaml's command line (comment intact) turns the pin red, for the flag and not for vacuity" + +# --------------------- 7. this gate's OWN PR-visible step (RELSE-08) -- +# +# Review finding F1: every assertion above is worthless on a pull request if the +# only path from this script to CI is `task check` -> release-exitgate, which +# carries `if: github.event_name != 'pull_request'`. A PR that strips -count=1 +# from verify.yaml would then merge green and redden main afterwards — the +# lane's own thesis failing to apply to the lane. Mirrors the AUD2-S05 pin in +# hack/audit/aud2_exitgate_test.sh: the step must exist in the PR-visible +# `verify` job, be argument-free, and carry no `if:`/`continue-on-error:`; the +# job must carry no job-level `if:`; and the workflow must still trigger on +# pull_request at all. + +SELF_REL="hack/examples/dogfood_wiring_test.sh" + +# extract_on_block — the body of the top-level `on:` mapping. +extract_on_block() { + awk '/^on:/ { ino = 1; next } ino && /^[A-Za-z]/ { ino = 0 } ino { print }' "$1" +} + +# extract_job — the body of a 2-space-indented jobs: entry. +extract_job() { + awk -v name="$2" ' + $0 == " " name ":" { inb = 1; next } + inb && /^ [A-Za-z0-9_.-]+:[[:space:]]*$/ { inb = 0 } + inb { print } + ' "$1" +} + +# isolate_step_at — the step containing that line, +# from its `- ` marker to the next one. +isolate_step_at() { + local blockfile="$1" hitline="$2" start end + start="$(grep -nE '^ - ' "$blockfile" | cut -d: -f1 | awk -v h="$hitline" '$1 <= h { s = $1 } END { print s }')" + [[ -n "$start" ]] || return 1 + end="$(awk -v s="$start" 'NR > s && /^ - / { print NR; exit }' "$blockfile")" + [[ -n "$end" ]] || end="$(($(wc -l <"$blockfile") + 1))" + sed -n "${start},$((end - 1))p" "$blockfile" +} + +# SELF_RE — SELF_REL as an ERE (the dot in `.sh` must not be a wildcard). +SELF_RE="${SELF_REL//./\\.}" + +# pr_step_pinned — 0 when this gate runs, undisarmed, in a +# pull-request-visible job. Distinct codes so a mutation control can prove it +# went red for ITS reason: 2 no pull_request trigger, 3 job block unextractable, +# 4 job-level if:, 5 step absent, 6 step unisolatable, 7 arguments, 8 disarmed. +pr_step_pinned() { + local wf="$1" + extract_on_block "$wf" >"$WORK/on.block" + [[ -s "$WORK/on.block" ]] || return 2 + grep -qE '^[[:space:]]+pull_request:' "$WORK/on.block" || return 2 + extract_job "$wf" verify >"$WORK/job.verify" + { [[ -s "$WORK/job.verify" ]] && grep -q '^ steps:' "$WORK/job.verify"; } || return 3 + grep -qE '^ if:' "$WORK/job.verify" && return 4 + # Anchored on a `run:` COMMAND, never a mention: verify.yaml's step comments + # name this script ("Pinned by hack/examples/dogfood_wiring_test.sh"), and a + # fixed-string search would let those comments satisfy the presence check + # after the real invocation was deleted. Measured while building 7b. + local runline + runline="$(grep -nE "^[[:space:]]*run:[[:space:]]*bash[[:space:]]+${SELF_RE}([[:space:]]|\$)" "$WORK/job.verify" | head -1 | cut -d: -f1)" + [[ -n "$runline" ]] || return 5 + isolate_step_at "$WORK/job.verify" "$runline" >"$WORK/step.self" || return 6 + { [[ -s "$WORK/step.self" ]] && grep -qE '^ - ' "$WORK/step.self"; } || return 6 + # Isolation bound, the same defect hack/lint/workflow_pins_test.sh:433-440 + # closes with a 1..6 LINE cap. Without a bound, deleting only this step's + # `- name:` line merges it into the neighbouring step and the isolated region + # silently becomes that other step's body plus ours — a malformed workflow + # this function called fine (measured: rc=0). A line cap is the wrong shape + # here: this step carries a long comment block, so the honest size (14) and + # the merged size (15) are one line apart and any cap that admits the first + # admits the second. The structural invariant is what actionlint itself + # flags — a step has exactly ONE `run:` key — and it holds whatever the + # comments do. (Also caught independently by workflow_pins_test.sh's own + # "got 15 lines, expected 1..6" and by actionlint's `key "run" is + # duplicated`, so this is a bound made explicit, not a hole being closed.) + local n_run + n_run="$(grep -cE '^ run:' "$WORK/step.self" || true)" + ((n_run == 1)) || return 6 + grep -qE "^[[:space:]]*run:[[:space:]]*bash[[:space:]]+${SELF_RE}[[:space:]]*\$" "$WORK/step.self" || return 7 + grep -qE '^[[:space:]]*(if|continue-on-error):' "$WORK/step.self" && return 8 + return 0 +} + +echo "== 7. this gate runs in the pull-request-visible verify job, undisarmed ==" +rc=0; pr_step_pinned "$WORKFLOW" || rc=$? +case "$rc" in + 0) echo "OK: the 'verify' job (which fires on pull_request) runs $SELF_REL, argument-free and undisarmed" ;; + 2) fail "verify.yaml no longer triggers on pull_request — every pin in this script would reach CI only via release-exitgate, which skips PRs (RELSE-08)" ;; + 3) fail "the 'verify' job block extracted empty or without 'steps:' — the PR-visibility assertions would be vacuous" ;; + 4) fail "the 'verify' job carries a JOB-LEVEL if: — if it skips pull requests this gate is push-only exactly like release-exitgate, and RELSE-08 is reproduced" ;; + 5) fail "the pull-request-visible 'verify' job does not invoke $SELF_REL — this gate would run only via release-exitgate (skipped on PRs), so a PR that strips -count=1 from verify.yaml merges green and reddens main afterwards (RELSE-08)" ;; + 6) fail "could not isolate this gate's step in the verify job — the extraction broke, so the disarm assertions would be vacuous" ;; + 7) fail "the step invokes $SELF_REL WITH ARGUMENTS — an argument-carrying invocation is how a wired-looking step is hollowed out" ;; + 8) fail "the step is present but DISARMED — an 'if:' or 'continue-on-error:' means a red gate does not fail the PR" ;; + *) fail "pr_step_pinned returned an unmapped code $rc" ;; +esac + +echo "== 7b. every branch of the PR-visibility pin proved capable of going RED ==" +# Bound, stated rather than implied: rc=3 (verify job block unextractable — +# reachable by renaming the `verify:` job) has NO control here. rc=6 is +# controlled for the reachable route (a step merged into its neighbour by a +# deleted `- name:`); its other route, a region that is not a step at all, +# needs a workflow actionlint cannot parse and so is unreachable through green +# CI. Every other branch below is controlled. +expect_rc() { #