Skip to content

Commit ba2e67b

Browse files
mattmilleraiclaude
andauthored
fix(pr-risk): publish the Check Run on a manual dispatch regardless of enabled (#319)
* fix(pr-risk): publish the Check Run on a manual dispatch regardless of `enabled` `enabled` governs AUTOMATIC grading — the `pull_request` stream — and the `grade` job has been exempt on `workflow_dispatch` since #116, so a repo enrolled OFF can dispatch one PR and see what the grader would say. But `publish-check` was still gated on `needs.gate.outputs.enabled == 'true'`, so that preview graded, labelled and commented while silently omitting the one immutable, commit-attached artifact it exists to demonstrate. The clause was never a stated decision: #120 added the job 35 minutes after #116 landed the exemption and copied the older gate shape without it — its commit message and the job's own header comment name only `check_run`. The gate now follows GRADE having run. Not `inputs.check_run && !cancelled()` alone: `!cancelled()` is a status function, so it replaces the implicit "all needs succeeded" check and the job would then RUN on every disabled `pull_request` run — where `grade` was merely skipped — booting a runner to publish nothing, which is the cost the cheap `gate` job exists to avoid. `!= 'skipped'` rather than `== 'success'` keeps publishing the targets that DID render when grade fails partway, which the step already tolerates. Behaviour is unchanged for every `pull_request` run and for any caller with `enabled: true`; the only row of the truth table that moves is a manual dispatch on a repo whose `enabled` resolves false. Also: the disabled-dispatch step summary said the run "will sync a `risk:*` label" while the sticky comment (posted from inside `grade`) already rode along with it, and now the Check Run does too — so it names all three. New `tests/test_job_gates.sh` pins both job gates structurally. A job `if:` exists only in the workflow and is evaluated before any step is dispatched, so no script suite can reach it: `resolve-enabled.sh` can be green on every input it takes and still have its answer consumed by the wrong job. * test(pr-risk): pin the job gates by equality, and scan the whole publish-check job Review of the new `test_job_gates.sh` found it proved less than it claimed. Every assertion was a substring-presence needle, and a needle sees a TERM but never how the terms are JOINED, so gates meaning the opposite of the pinned contract passed: * `needs.grade.result != 'skipped' || inputs.check_run || !cancelled()` contains every positive needle and none of the forbidden one — yet `!cancelled()` alone then boots a `checks: write` runner on every non-cancelled run, which is the skipped-needs spin the file's own header says it guards. * `!inputs.check_run` satisfies an `inputs.check_run` needle while inverting the opt-in. * The one structural guard keyed on the literal `inputs.check_run && !cancelled()` spelling, so any reordering walked past it. Both expressions are now pinned by EQUALITY, the division `test_pin_contract.sh` already settled on for the `workflows_ref` guard: the needles stay, but as restatements that name WHICH invariant broke rather than as the defence. Three gaps the equality does not close, each closed on its own: * `grade` in `needs:` was never asserted, and the gate's safety rests entirely on it — drop it and `needs.grade.result` resolves to empty, `'' != 'skipped'` is true, and the job both stops waiting for grade and runs on every non-cancelled run, with the equality still green. * The slicer stopped at the job's `name:` key, so it never saw the steps. A step-level `if: needs.gate.outputs.enabled == 'true'` on `Create the Check Run(s)` reproduced the dispatch-preview-publishes-nothing mode with the file fully green. The forbidden term is now scanned over the whole job body. * That same `name:`-bounded slice ran ON into the following job when a `name:` was renamed or dropped, concatenating two jobs' `if:`s into one blob where either could satisfy a needle. It now bounds on the next 2-space job key. The slicer was fragile in the other direction too: continuation was matched by six-space indent, so a blank line inside the `>-` scalar — legal YAML — truncated the expression and every NEGATIVE assertion passed over a prefix. Confirmed against the real file: it extracted `needs.grade.result != 'skipped' &&` and nothing more. Blank lines are continuations now; only a real 4-space key ends the block. The comment strip also had to move AHEAD of the terminator, since this workflow divides its jobs with comment blocks at job indent and terminating on one truncates the body — which is a vacuous pass for the whole-job scan, the half with no equality behind it. `job_needs` reads block style as well as flow, so reformatting the key is not a spurious red. Also, per review: the disabled-dispatch staleness warning named only the label, while the sentence above it now tells the reader the run posts a sticky comment too. That comment is refreshed only by a later re-grade, which a disabled repo never performs, so it keeps showing this moment's tier while subsequent commits go ungraded. It is named in the warning now; the Check Run is called out as needing no such caveat, being pinned to the commit it graded. Negative-checked, since a test that cannot fail is the thing being fixed: all seven regressions above are caught (3, 2, 1, 1, 5, 3 and 2 failures respectively), and the two benign reformats — a blank line in the scalar, a block-style `needs:` — stay green. `pr-risk.yml` is byte-identical to before; only the two scripts and the suite changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent cb1f60a commit ba2e67b

5 files changed

Lines changed: 261 additions & 7 deletions

File tree

‎.github/workflows/pr-risk.yml‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,10 @@ on:
406406
same PR or its next run fails before a step executes. The separate job still earns its
407407
keep: it is the only place `checks: write` is ever HELD, and it reads nothing from a
408408
pull request, so no PR-controlled text is ever rendered under the elevated token.
409+
410+
411+
Published whenever `grade` ran — including a manual `workflow_dispatch` on a repo whose
412+
`enabled` resolves false — so the staged-rollout preview shows the Check Run too.
409413
type: boolean
410414
required: false
411415
default: false
@@ -520,7 +524,8 @@ on:
520524
leaves whatever the last enabled one left, it does not clean up).
521525
522526
523-
A MANUAL `workflow_dispatch` GRADES REGARDLESS. Dispatching requires
527+
A MANUAL `workflow_dispatch` GRADES REGARDLESS (and, when opted in,
528+
posts the sticky comment and the Check Run). Dispatching requires
524529
write access and is an explicit, attributed act, so there is no
525530
unattended behaviour left for this switch to govern — and without that
526531
exemption the staged rollout this input exists for cannot happen: a
@@ -1125,9 +1130,23 @@ jobs:
11251130
#
11261131
# Keeping it in its OWN job is still what limits the blast radius: `checks: write` is held here
11271132
# and nowhere else, and the grade job — the one that reads pull requests — keeps `checks: read`.
1133+
#
1134+
# IT FOLLOWS `grade`, NOT `enabled`: whenever the grade job ran this job publishes, including on
1135+
# a manual `workflow_dispatch` in a repo whose `enabled` resolves false.
11281136
publish-check:
11291137
needs: [gate, grade]
1130-
if: needs.gate.outputs.enabled == 'true' && inputs.check_run && !cancelled()
1138+
# Gated on GRADE having run, not on `enabled`: `enabled` governs the automatic `pull_request`
1139+
# stream only (see the `grade` job), and a manual dispatch that grades, labels and comments must
1140+
# also publish the one commit-attached artifact the staged-rollout preview exists to show. NOT
1141+
# `inputs.check_run && !cancelled()` alone: `!cancelled()` is a non-success() status function,
1142+
# so with no result test the job would RUN on every disabled `pull_request` run (its needs were
1143+
# merely skipped) and boot a runner to publish nothing — the cost the `gate` job exists to avoid.
1144+
# `!= 'skipped'` rather than `== 'success'` keeps publishing the targets that DID render when
1145+
# grade fails partway, which the step below already tolerates.
1146+
if: >-
1147+
needs.grade.result != 'skipped' &&
1148+
inputs.check_run &&
1149+
!cancelled()
11311150
name: Publish the risk Check Run
11321151
runs-on: ubuntu-latest
11331152
timeout-minutes: 10

‎.github/workflows/test-pr-risk.yml‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ jobs:
4848
persist-credentials: false
4949

5050
- name: shellcheck
51-
run: shellcheck -x grade-pr-risk.sh apply-risk-label.sh grade-targets.sh lib.sh resolve-enabled.sh tests/test_grade_pr_risk.sh tests/test_apply_risk_label.sh tests/test_grade_targets.sh tests/test_resolve_enabled.sh tests/test_pin_contract.sh publish-risk-surfaces.sh tests/test_publish_risk_surfaces.sh
51+
run: shellcheck -x grade-pr-risk.sh apply-risk-label.sh grade-targets.sh lib.sh resolve-enabled.sh tests/test_grade_pr_risk.sh tests/test_apply_risk_label.sh tests/test_grade_targets.sh tests/test_resolve_enabled.sh tests/test_pin_contract.sh tests/test_job_gates.sh publish-risk-surfaces.sh tests/test_publish_risk_surfaces.sh
5252

5353
- name: default map + registry parse and validate
5454
# The shipped defaults must pass the grader's own structural validation:
@@ -77,6 +77,11 @@ jobs:
7777
# copies have not drifted, and neither enforcement axis has been dropped.
7878
run: bash tests/test_pin_contract.sh
7979

80+
- name: job-gate suite
81+
# The job-level `if:` conditions in pr-risk.yml — which jobs `enabled` governs and which
82+
# follow `grade` — the other piece of logic no script suite can reach.
83+
run: bash tests/test_job_gates.sh
84+
8085
- name: publish-surfaces suite
8186
# The two OPT-IN surfaces (Check Run + sticky comment). The properties worth the most
8287
# here are the ones an estimate cannot establish: that a crafted FILENAME cannot forge a

‎scripts/pr-risk/resolve-enabled.sh‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,14 @@ if [ "$enabled" != true ] && [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
7070
{
7171
echo "## Automatic PR risk grading is OFF for this repository"
7272
echo
73-
echo "This MANUAL run grades anyway and will sync a \`risk:*\` label — \`enabled\` governs"
74-
echo "the \`pull_request\` stream, not a dispatch anyone with write access chose to run."
73+
echo "This MANUAL run grades anyway and will sync a \`risk:*\` label and, where the caller"
74+
echo "opted in, post the sticky comment and the Check Run — \`enabled\` governs the"
75+
echo "\`pull_request\` stream, not a dispatch anyone with write access chose to run."
7576
echo
76-
echo "So the label this run leaves will NOT be kept up to date by pushes: until grading is"
77-
echo "switched on, it reflects this moment only and every later commit will go ungraded."
77+
echo "So the label this run leaves — and the sticky comment, where the caller opted in —"
78+
echo "will NOT be kept up to date by pushes: until grading is switched on, they reflect"
79+
echo "this moment only and every later commit will go ungraded. The Check Run carries no"
80+
echo "such caveat: it is pinned to the commit it graded, so it stays true of that commit."
7881
echo
7982
echo "Decided by ${decided_by}. To grade automatically from here on, set the repository"
8083
echo 'variable `RISK_CONFIG` to `{"enabled": true}` (no PR needed), or pass'
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
#!/usr/bin/env bash
2+
# test_job_gates.sh — hermetic structural tests for the JOB-LEVEL `if:` conditions in
3+
# .github/workflows/pr-risk.yml. No network, no Actions: this reads the workflow file as text.
4+
#
5+
# WHY A SCRIPT SUITE CANNOT COVER THIS. Every other pr-risk suite tests a script, and a job `if:`
6+
# is not in one — it exists only in the workflow, is evaluated by the runner before any step is
7+
# dispatched, and decides whether a script is invoked at all. `resolve-enabled.sh` can be green on
8+
# every input it takes and still have its answer consumed by the wrong job, because which jobs
9+
# read `enabled` is not a fact any script can see. So the gates get the same treatment
10+
# `test_pin_contract.sh` gives the `workflows_ref` guard: a structural read over the YAML text.
11+
#
12+
# THE TWO FAILURE MODES THIS FILE PINS, both of which ship green through every script suite:
13+
#
14+
# * THE DISPATCH PREVIEW PUBLISHES NOTHING. `enabled` governs the AUTOMATIC `pull_request`
15+
# stream only — the `grade` job is exempt on `workflow_dispatch` precisely so a repo enrolled
16+
# OFF can grade one PR and see what the grader would say. Re-gating `publish-check` on
17+
# `needs.gate.outputs.enabled` silently removes the Check Run from that preview: the run
18+
# grades, labels and comments, and the one immutable commit-attached artifact the preview
19+
# exists to demonstrate is the single thing missing. Nothing fails; the reader just concludes
20+
# the feature does not work.
21+
#
22+
# * THE SKIPPED-NEEDS RUNNER SPIN. The tempting minimal fix is `inputs.check_run &&
23+
# !cancelled()`. But `!cancelled()` is a status function, and ANY status function in an `if:`
24+
# replaces the implicit "all needs succeeded" check — so on a disabled `pull_request` run,
25+
# where `grade` was merely SKIPPED, `publish-check` RUNS: it boots a runner, finds an empty
26+
# `surfaces`, publishes nothing and exits 0. Green, invisible, and one wasted runner per
27+
# disabled PR across every enrolled repo — exactly the cost the cheap `gate` job exists to
28+
# avoid. Hence the `needs.grade.result` test, which is what makes the `!cancelled()` safe.
29+
#
30+
# WHY `!= 'skipped'` AND NOT `== 'success'`, restated here because the two read alike: the publish
31+
# step already tolerates a partial render (an empty `surfaces` is "nothing to publish", not an
32+
# error, and per-target failures are warnings). A `success()`-shaped gate would throw away the
33+
# targets that DID render whenever grade failed partway.
34+
#
35+
# The grade side is pinned too, as the other half of the same contract: PR #116's dispatch
36+
# exemption is what makes the publish-check gate correct, so a change that drops it must fail
37+
# here rather than leave this file asserting a preview that no longer grades.
38+
#
39+
# THE EXPRESSIONS ARE PINNED BY EQUALITY, and the named needles below are RESTATEMENTS, not the
40+
# defence — the same division `test_pin_contract.sh` settled on, for the same reason. A needle
41+
# proves a term is PRESENT and nothing about how it is JOINED, so a scan built only from needles
42+
# passes on gates that mean the opposite of what it claims to pin: `needs.grade.result !=
43+
# 'skipped' || inputs.check_run || !cancelled()` contains every positive needle and none of the
44+
# forbidden one, yet `!cancelled()` alone then boots a `checks: write` runner on every
45+
# non-cancelled run — the skipped-needs spin above. `!inputs.check_run` likewise satisfies an
46+
# `inputs.check_run` needle while inverting the opt-in. Only the equality sees the operators. The
47+
# needles stay because they name WHICH invariant broke instead of only printing two strings.
48+
#
49+
# Editing a gate on purpose is therefore a deliberate two-place edit — the workflow and the
50+
# expected string here — whose diff a reviewer sees. That is the property, not an obstacle.
51+
#
52+
# THE GATE IS NOT THE ONLY PLACE `enabled` COULD COME BACK. A step-level `if:` on `Create the
53+
# Check Run(s)` reproduces the dispatch-preview-publishes-nothing mode exactly, with a job gate
54+
# that still reads correctly. So the forbidden `needs.gate.outputs.enabled` is scanned for over
55+
# the WHOLE publish-check job body, not just its `if:`. And the gate's safety rests on `grade`
56+
# being in `needs:` — drop it and `needs.grade.result` resolves to the empty string, `'' !=
57+
# 'skipped'` is true, and the job both stops waiting for grade and runs on every non-cancelled
58+
# run. That membership is asserted too.
59+
#
60+
# Extraction is by anchor, so every scan self-checks that it matched something — a stale anchor
61+
# fails loudly rather than printing a vacuous `ok` over an empty string.
62+
#
63+
# bash tests/test_job_gates.sh # exit 0 = all green
64+
set -uo pipefail
65+
66+
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
67+
WF="$SELF_DIR/../../../.github/workflows/pr-risk.yml"
68+
[ -f "$WF" ] || { echo "FATAL: $WF not found" >&2; exit 1; }
69+
70+
PASS=0; FAIL=0
71+
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
72+
bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n got: %s\n' "$1" "${2:-}"; }
73+
eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (expected '$2')" "$3"; fi }
74+
# `grep -F`, not `case` globbing: these needles are workflow-expression syntax (`!cancelled()`,
75+
# `needs.gate.outputs.enabled`), whose parens and dots a glob or a regex would reinterpret into
76+
# something laxer.
77+
has() { if printf '%s\n' "$2" | grep -qF -- "$3"; then ok "$1"; else bad "$1" "not found: $3"; fi }
78+
no() { if printf '%s\n' "$2" | grep -qF -- "$3"; then bad "$1" "present: $3"; else ok "$1"; fi }
79+
80+
# --- exactly one of each job, which is what makes the extraction below meaningful --------------
81+
# The slicer keys on a 2-space-indented job key. A second job of the same name is not valid YAML,
82+
# but a RENAME that leaves the old key behind, or a copy-pasted block, would have it silently
83+
# read the FIRST copy while the runner obeys the last. Count first.
84+
count_job() { grep -cE "^ $1:[[:space:]]*(#.*)?\$" "$WF"; }
85+
eq "exactly one grade: job" 1 "$(count_job grade)"
86+
eq "exactly one publish-check: job" 1 "$(count_job 'publish-check')"
87+
88+
# --- the whole body of a named job, WHOLE-LINE comments stripped -------------------------------
89+
# From the job's own 2-space key to the NEXT 2-space key (or EOF). Bounding on the next job — not
90+
# on this job's `name:`, as an earlier draft did — is what keeps a rename or a dropped `name:`
91+
# from running the slice on into the following job, where a needle satisfied by somebody else's
92+
# gate reads as green. Whole-line comments go because the rationale above `publish-check`'s gate
93+
# QUOTES the very anti-pattern this file forbids, so a comment-blind scan would fail on the prose
94+
# explaining why the code is right.
95+
#
96+
# The comment strip must come BEFORE the terminator, not after: a 2-space `# ...` line is not a
97+
# job key, and this workflow separates its jobs with sixteen-line comment blocks at exactly that
98+
# indent. Terminating on one would end the slice early — harmless between jobs, but a truncated
99+
# body is a VACUOUS pass for the whole-job `enabled` scan below, which is the half that has no
100+
# equality behind it.
101+
job_body() {
102+
awk -v job="$1" '
103+
$0 ~ "^ " job ":[[:space:]]*(#.*)?$" { injob = 1; next }
104+
!injob { next }
105+
/^[[:space:]]*#/ { next }
106+
/^ [^[:space:]]/ { injob = 0; next }
107+
{ print }
108+
' "$WF"
109+
}
110+
111+
# --- that job's `if:` expression, folded to one line, the key and block indicator stripped -----
112+
# Keep the `if:` line and its continuations, then squash all whitespace to single spaces. The
113+
# squash is load-bearing, not cosmetic: a `>-` scalar splits the expression across lines, so a
114+
# multi-token needle would never match the raw text and every negative assertion below would pass
115+
# vacuously. A BLANK line is a continuation, not a terminator — a blank inside a `>-` scalar is
116+
# legal YAML, and treating it as the end would silently truncate the expression to a prefix and
117+
# let the equality and the `no` needles pass over the half they never saw. Only a real 4-space
118+
# key ends the block.
119+
job_if() {
120+
job_body "$1" | awk '
121+
/^ if:/ { inif = 1; print; next }
122+
!inif { next }
123+
/^[[:space:]]*$/ { next }
124+
/^ / { print; next }
125+
{ inif = 0 }
126+
' | tr '\n' ' ' | tr -s '[:space:]' ' ' \
127+
| sed -E 's/^[[:space:]]*if:[[:space:]]*(>-|>|\|-|\|)?[[:space:]]*//; s/[[:space:]]+$//'
128+
}
129+
130+
# --- that job's `needs:`, normalized to a bare comma list --------------------------------------
131+
# Flow style (`needs: [gate, grade]`) and block style (`- gate` on following lines) both reduce to
132+
# `gate,grade`, so a pure reformat of the key does not turn into a spurious red here.
133+
job_needs() {
134+
job_body "$1" | awk '
135+
/^ needs:/ { sub(/^ needs:[[:space:]]*/, ""); v = $0; inneeds = 1; next }
136+
inneeds && /^ *- / { sub(/^[[:space:]]*-[[:space:]]*/, ""); v = v (v == "" ? "" : ",") $0; next }
137+
inneeds { exit }
138+
END { gsub(/[][[:space:]]/, "", v); print v }
139+
'
140+
}
141+
needs_has() { case ",$2," in *",$3,"*) ok "$1" ;; *) bad "$1" "needs: ${2:-<none>}" ;; esac }
142+
143+
GRADE_IF="$(job_if grade)"
144+
PUBLISH_IF="$(job_if 'publish-check')"
145+
PUBLISH_BODY="$(job_body 'publish-check')"
146+
147+
# Coverage self-checks. Without these, a slicer whose anchors went stale returns "" and every
148+
# `has` below reports a real failure while every `no` reports a fake success — the worse half.
149+
if [ -n "$GRADE_IF" ]; then ok "the grade if: was extracted ($GRADE_IF)"
150+
else bad "the grade if: was extracted" "empty — the slicer's anchors are stale"; fi
151+
if [ -n "$PUBLISH_IF" ]; then ok "the publish-check if: was extracted ($PUBLISH_IF)"
152+
else bad "the publish-check if: was extracted" "empty — the slicer's anchors are stale"; fi
153+
if [ -n "$PUBLISH_BODY" ]; then ok "the publish-check job body was extracted"
154+
else bad "the publish-check job body was extracted" "empty — the slicer's anchors are stale"; fi
155+
# The slice must stop at the job it belongs to. Assert it on `grade`, the one with a successor:
156+
# publish-check is last, so its own slice running long has nothing to run into and would prove
157+
# nothing. `needs.grade.result` appears only in publish-check, so seeing it inside grade's body
158+
# means the two jobs were concatenated — the exact failure a `name:`-bounded slicer had.
159+
no "the grade slice stops before the next job" "$(job_body grade)" "needs.grade.result"
160+
no "grade's if: did not absorb publish-check's" "$GRADE_IF" "inputs.check_run"
161+
162+
# --- THE PINS. The expressions verbatim; everything below them only names what broke -----------
163+
eq "the grade gate is exactly what it should be" \
164+
"needs.gate.outputs.enabled == 'true' || github.event_name == 'workflow_dispatch'" \
165+
"$GRADE_IF"
166+
eq "the publish-check gate is exactly what it should be" \
167+
"needs.grade.result != 'skipped' && inputs.check_run && !cancelled()" \
168+
"$PUBLISH_IF"
169+
170+
# --- grade: `enabled` OR a manual dispatch ----------------------------------------------------
171+
# PR #116's exemption. Dropping the dispatch arm would make `enabled: false` a lockout rather than
172+
# a mute and delete the staged rollout the input exists for; dropping the `enabled` arm would make
173+
# every enrolled repo grade whatever the switch says.
174+
has "grade is gated on the resolved enabled flag" "$GRADE_IF" "needs.gate.outputs.enabled == 'true'"
175+
has "grade exempts a manual workflow_dispatch" "$GRADE_IF" "github.event_name == 'workflow_dispatch'"
176+
needs_has "grade waits on gate" "$(job_needs grade)" "gate"
177+
178+
# --- publish-check: follows GRADE, never `enabled` --------------------------------------------
179+
# `grade` in `needs:` is what gives `needs.grade.result` a value at all. Without it the term
180+
# resolves to the empty string, `'' != 'skipped'` is TRUE, and the gate silently degenerates into
181+
# the `!cancelled()`-only form this file exists to forbid — with every needle below still green.
182+
needs_has "publish-check waits on grade" "$(job_needs 'publish-check')" "grade"
183+
has "publish-check is gated on grade having run" "$PUBLISH_IF" "needs.grade.result != 'skipped'"
184+
has "publish-check still honours the opt-in input" "$PUBLISH_IF" "inputs.check_run"
185+
no "publish-check does not INVERT the opt-in" "$PUBLISH_IF" "!inputs.check_run"
186+
has "publish-check still runs on a failed grade" "$PUBLISH_IF" '!cancelled()'
187+
has "publish-check joins its terms with AND" "$PUBLISH_IF" "&&"
188+
no "publish-check joins no term with OR" "$PUBLISH_IF" "||"
189+
190+
# THE REGRESSION THIS FILE EXISTS FOR. `needs.gate.outputs.enabled` anywhere in this job is the
191+
# dispatch-preview-publishes-nothing failure mode: it re-imposes the automatic stream's switch on
192+
# a manual run that grades, labels and comments regardless. Scanned over the WHOLE job, because a
193+
# step-level `if:` on the publishing step reproduces it exactly while the job gate still reads
194+
# right — and the job gate is the only thing an `if:`-only scan can see.
195+
no "publish-check is NOT gated on enabled" "$PUBLISH_IF" "needs.gate.outputs.enabled"
196+
no "and no step inside publish-check re-imposes it either" "$PUBLISH_BODY" "needs.gate.outputs.enabled"
197+
198+
# THE OTHER HALF, and it is a CONJUNCTION rather than a needle: `inputs.check_run && !cancelled()`
199+
# is a legitimate SUBSTRING of the correct gate, so its presence proves nothing on its own. What
200+
# must never happen is a status function standing WITHOUT a `needs.grade.result` test — the
201+
# skipped-needs runner spin, which no script suite and no green workflow run would ever reveal.
202+
if printf '%s\n' "$PUBLISH_IF" | grep -qE -- '!?(cancelled|always|success|failure)\(\)' \
203+
&& ! printf '%s\n' "$PUBLISH_IF" | grep -qF -- 'needs.grade.result'; then
204+
bad "publish-check never pairs a status function with no needs.grade.result test" "$PUBLISH_IF"
205+
else
206+
ok "publish-check never pairs a status function with no needs.grade.result test"
207+
fi
208+
209+
printf '\n%s passed, %s failed\n' "$PASS" "$FAIL"
210+
[ "$FAIL" -eq 0 ]

0 commit comments

Comments
 (0)