Skip to content

Commit 8ebc8fb

Browse files
wshallwshallclaude
andcommitted
ci(signal): name the JOB that ejected a pull request, not the workflow
The attribution step has been titled "Say which job ejected it" since BACKLOG #1403 and interpolated `github.event.workflow_run.name` -- the WORKFLOW name. So every merge-queue ejection comment read "CI failed ...", a fact the reader already had from the label and the checks tab, and named no job at all. #1403's banner reads closed over that gap; this is its unmet acceptance. git show origin/main:.github/workflows/failure-signal.yml # step "Say which job ejected it ..." -> RUN_NAME: ${{ github.event.workflow_run.name }} The step now reads the failing run's jobs and names the leg and its first failing step: "CI failed in web console tests (windows-2025, py3.14) / Step margin -- web console suite while this pull request was in the merge queue ...". It refuses to name `CI gate`, which fails in every red run and whose own failing step points at a leg it does not name -- the same refusal `scripts/ci/report_ci_red.py` makes, and a test now compares the two rather than trusting them to agree. THE TOKEN QUESTION, RESOLVED EMPIRICALLY rather than assumed either way. Throwaway run 35171248441 carried this job's exact permissions block minus the new line. The runner printed `Issues: write / Metadata: read / PullRequests: write` -- no Actions entry, so the scope really was `none` -- and `GET /actions/runs/<id>/jobs` still returned 200 with the job names. Unauthenticated curl returned 200 too, and a control call to `/actions/permissions` was refused in the same job, so those successes are not a probe that cannot see a 403. `actions: read` is declared anyway, ALONGSIDE the two existing writes. That 200 carried `X-Accepted-Github-Permissions: actions=read`: the endpoint's stated requirement IS the scope, and it is served without it only because this repository is public. Resting the attribution on that accident would make the job name vanish the day the repo turns private or GitHub enforces its own declaration -- silently, with the comment still posting, which is this defect wearing the same clothes and found months later. It is a READ scope and cannot modify code. The rule is a Python program in the step's `env:` rather than inline jq because it has to be TESTED. This workflow may not check the repository out -- the zizmor suppression rests on it running no third-party code -- so it cannot import the identical rule from `report_ci_red.py`. Held in `env:` it is a string the tests read out of the file and RUN, so what is exercised is the bytes that ship. IT FAILS SOFT, AND SAYS WHICH WAY. An ejection comment is the only record there is, so a refused fetch degrades to the old workflow-only text rather than posting nothing -- and a rule that CRASHED gets its own annotation, distinct from "no job failed", which a bare `|| true` would have made indistinguishable. All four paths were run end to end against the shipped shell with a stubbed `gh`. `test_it_is_least_privilege_and_cannot_modify_code` is split in two. It now asserts the WRITE SET alone; the exact permissions block is a separate test. The two used to be one assertion, which meant granting any scope re-blessed the security claim in the same keystroke. Proven: revoking `actions: read` reds only the exact-block test and leaves the write-set test green, while adding `contents: write` reds the write-set test on its own. MUTATION-PROVEN, eight ways: drop the roll-up refusal, drop the step name, count a cancelled sibling, stop fetching the jobs, revoke the scope, add `contents: write`, unguard the fetch, drift the roll-up name from the reader's. Each reddens exactly the test that claims it and the baseline is green after restore. KNOWN RESIDUAL, recorded in the file's header rather than fixed: this workflow watches CI, Security and backlog-hygiene, and does NOT watch the three advisory crons that rule on CI's own health -- `required-workflow-state.yml`, the new `ci-red-report.yml`, and `stalled-prs.yml`. A red one reaches nobody, which `.github/required-contexts.txt` already records happening for about a day. It is written as prose, not as a `not-watched:` line, for the same reason CodeQL's is: that register is scoped to REQUIRED workflows and an exclusion naming a non-required one reds `test_a_recorded_exclusion_names_a_real_workflow_that_really_is_required`. Adding them to the watch list is not the fix either -- the label says a REQUIRED check went red, which is false of an advisory cron. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 23372f4 commit 8ebc8fb

2 files changed

Lines changed: 299 additions & 9 deletions

File tree

.github/workflows/failure-signal.yml

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,24 @@
6262
# request that nothing was stopping. A signal that misreports what it means is worse than no signal,
6363
# which is the same argument the cla.yml exclusion below makes in its own words.
6464
#
65+
# THE ADVISORY CRONS ARE UNWATCHED, AND THAT IS AN OPEN GAP RATHER THAN A DECISION. Three workflows
66+
# here rule on CI's own health on a schedule -- `required-workflow-state.yml` (two jobs),
67+
# `ci-red-report.yml`, `stalled-prs.yml` -- and none of them appears in the `workflows:` list above. A
68+
# red one therefore signals NOBODY, which is measured rather than feared: `.github/required-contexts.txt`
69+
# records `check_required_contexts_drift.py` detecting real drift and reporting to nobody for about a
70+
# day, because its workflow is not required and nothing watched it.
71+
#
72+
# IT IS WRITTEN HERE AS PROSE AND NOT AS A `not-watched:` LINE, for the same reason CodeQL's exclusion
73+
# is prose: that register is scoped to REQUIRED workflows by construction, and
74+
# `tests/test_failure_signal.py::test_a_recorded_exclusion_names_a_real_workflow_that_really_is_required`
75+
# reddens on an exclusion naming a workflow that reports no required context. None of the three does.
76+
#
77+
# ADDING THEM TO THE WATCH LIST IS NOT THE OBVIOUS FIX, which is why this is recorded rather than
78+
# done. The label reads "A required check went red. Attribute it before retrying." That sentence is
79+
# false of an advisory cron exactly as it was false of a CodeQL red, and the paragraphs above deleted
80+
# a watch for precisely that. Closing this needs a second signal whose text is true, not this one
81+
# pointed at more workflows.
82+
#
6583
# WHAT IT COSTS, STATED HONESTLY SO NOBODY OVERSELLS IT: about 1,236 workflow-run records a week stop
6684
# being created. Those are RECORDS, not runner slots -- the `signal` job's `if:` is false on a
6785
# non-failure, so a skipped job starts no runner -- and the removal only reaches the failures anyway.
@@ -100,6 +118,26 @@ jobs:
100118
runs-on: ubuntu-latest
101119
timeout-minutes: 10
102120
permissions:
121+
# `actions: read` backs the jobs fetch in the attribution step below, and it is declared even
122+
# though this repository is PUBLIC and the endpoint answers without it. MEASURED 2026-09-16 on
123+
# run 35171248441, a throwaway workflow carrying this exact block minus this line: the runner
124+
# printed `Issues: write / Metadata: read / PullRequests: write` -- no Actions entry, so the
125+
# scope really was `none` -- and `GET /actions/runs/<id>/jobs` still returned 200 with the job
126+
# names. An unauthenticated `curl` of the same URL returned 200 too. A control call to
127+
# `/actions/permissions` was refused in the same job, so the two successes are not a probe that
128+
# cannot see a 403.
129+
#
130+
# IT IS DECLARED ANYWAY BECAUSE GITHUB'S OWN RESPONSE ASKS FOR IT. That 200 carried the header
131+
# `X-Accepted-Github-Permissions: actions=read`: the endpoint's stated requirement IS this
132+
# scope, and it is served without it only because the repository is public. Resting the
133+
# attribution on that would make the job name vanish the day the repo turns private or GitHub
134+
# enforces its own declaration -- silently, with the comment still posting, which is defect
135+
# #1403 wearing the same clothes and found months later.
136+
#
137+
# It is a READ scope and cannot modify code, so the escalation property the zizmor suppression
138+
# rests on is untouched. `tests/test_failure_signal.py` asserts that separately, on the write
139+
# set rather than on this dict, so a future write scope cannot ride in behind this one.
140+
actions: read
103141
pull-requests: write
104142
issues: write
105143
steps:
@@ -148,8 +186,23 @@ jobs:
148186
# ejecting job may be one the pull request never ran (the path gates come off in the queue),
149187
# and the label alone names none of that.
150188
#
151-
# The step needs no new permission: `gh pr comment` posts an issue comment, and the job
152-
# already holds `issues: write` for the standing tracker below.
189+
# IT NAMES THE JOB, AND UNTIL 2026-09-16 IT DID NOT. The step has carried this title since
190+
# #1403 and interpolated `github.event.workflow_run.name`, which is the WORKFLOW name. So the
191+
# comment read "CI failed ..." -- a fact the reader already had from the label and the checks
192+
# tab -- while the title promised the job. #1403's banner reads closed over that gap; this is
193+
# its unmet acceptance, not a new idea.
194+
#
195+
# `gh pr comment` posts an issue comment and the job already holds `issues: write` for the
196+
# standing tracker below. The jobs fetch is what needs `actions: read`; see the job's
197+
# permissions block for why that scope is declared on a repo that answers without it.
198+
#
199+
# THE RULE IS PYTHON IN `env:`, NOT jq INLINE, FOR ONE REASON: it has to be TESTED. This
200+
# workflow may not check the repository out -- `test_it_pulls_in_no_third_party_actions`
201+
# forbids every `uses:`, and the zizmor suppression rests on that -- so the rule cannot import
202+
# `scripts/ci/report_ci_red.py`, which already implements it. Held in `env:` it is a plain
203+
# string, and `tests/test_failure_signal.py` reads it out of this file and RUNS it over real
204+
# payloads. A test that re-implemented the filter beside it would prove nothing about the
205+
# bytes that ship, which is the failure mode #1403 shipped under.
153206
#
154207
# TWO PLACES NOW TEST `merge_group` AND THEY ASK DIFFERENT QUESTIONS, so neither is a copy of
155208
# the other and neither should become an output of the other. The resolve step's shell gates
@@ -168,9 +221,71 @@ jobs:
168221
# body is a shell-injection hole regardless of which field happens to be safe today.
169222
RUN_NAME: ${{ github.event.workflow_run.name }}
170223
RUN_URL: ${{ github.event.workflow_run.html_url }}
224+
RUN_ID: ${{ github.event.workflow_run.id }}
225+
# THE ATTRIBUTION RULE. Mirrors `blame_job` in scripts/ci/report_ci_red.py, whose docstring
226+
# carries the measurements: prefer a real leg over the roll-up, and name the leg's FIRST
227+
# failing step. `CI gate` is the roll-up -- it fails in every red run and its own failing
228+
# step is `Fail -- a gated leg FAILED`, which points at a leg it does not name, so a reader
229+
# sent there reaches the one log guaranteed not to hold the answer. A roll-up that is the
230+
# ONLY failing job is still named: "I could not tell" must not render as "nothing failed".
231+
# Prints one line, or nothing at all when no job failed.
232+
BLAME_PY: |
233+
import json, sys
234+
235+
jobs = json.load(sys.stdin).get("jobs") or []
236+
failed = [
237+
j
238+
for j in jobs
239+
if isinstance(j, dict) and str(j.get("conclusion") or "").lower() == "failure"
240+
]
241+
legs = [
242+
j
243+
for j in failed
244+
if str(j.get("name") or "").split(" (", 1)[0].strip().lower() != "ci gate"
245+
]
246+
chosen = (legs or failed or [None])[0]
247+
if chosen is None:
248+
sys.exit(0)
249+
steps = [s for s in (chosen.get("steps") or []) if isinstance(s, dict)]
250+
step = next(
251+
(
252+
str(s.get("name") or "")
253+
for s in steps
254+
if str(s.get("conclusion") or "").lower() == "failure"
255+
),
256+
"",
257+
)
258+
print(str(chosen.get("name") or "") + (" / " + step if step else ""))
171259
run: |
172260
set -euo pipefail
173-
printf '%s failed while this pull request was in the merge queue, so the queue ejected it.\n\nIts own head can still be green: the queue revalidates the merge, and the path gates that skip on a pull request run there. Read the run before retrying.\n\n%s\n' "$RUN_NAME" "$RUN_URL" > ejection-body.md
261+
262+
# NEVER FATAL. A comment naming only the workflow is the behaviour this step is replacing,
263+
# so degrading to it beats posting nothing -- and an ejection comment is the only record
264+
# there is. The `if` guard keeps a non-zero `gh` exit from tripping `set -e`.
265+
# TWO GUARDS, NOT ONE `|| true`. A bare `|| true` on the rule would make a rule that CRASHED
266+
# -- no python3 on the image, a payload shape it chokes on -- indistinguishable from a run
267+
# in which no job failed: same empty string, same comment, no annotation anywhere. Each
268+
# failure mode announces itself instead. (`python3` is on every ubuntu-latest image; the
269+
# guard is for the day that premise moves, which is the only kind that moves silently.)
270+
where=""
271+
if gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" > jobs.json 2> jobs.err; then
272+
if ! where="$(python3 -c "$BLAME_PY" < jobs.json)"; then
273+
echo "::warning::the attribution rule failed on run $RUN_ID; the comment will name the workflow only."
274+
where=""
275+
fi
276+
else
277+
echo "::warning::could not read the jobs of run $RUN_ID; the comment will name the workflow only. $(head -c 300 jobs.err)"
278+
fi
279+
280+
if [ -n "$where" ]; then
281+
headline="$RUN_NAME failed in $where"
282+
caveat=""
283+
else
284+
headline="$RUN_NAME failed"
285+
caveat=" No failing job could be read from the run, so this names the workflow only -- open the run and read its job list."
286+
fi
287+
288+
printf '%s while this pull request was in the merge queue, so the queue ejected it.%s\n\nIts own head can still be green: the queue revalidates the merge, and the path gates that skip on a pull request run there. Read the run before retrying.\n\n%s\n' "$headline" "$caveat" "$RUN_URL" > ejection-body.md
174289
gh pr comment "$PR" --repo "$REPO" --body-file ejection-body.md
175290
176291
- name: Otherwise record it against the standing tracker

0 commit comments

Comments
 (0)