ci(signal): name the job that ejected a PR, and run the ci-red reader nothing invoked - #1240
wshallwshall wants to merge 2 commits into
Conversation
`scripts/ci/report_ci_red.py` landed 2026-09-04 with `tests/test_ci_red_reader.py`
beside it and NOTHING RAN IT. Verified 2026-09-16:
git grep -l "report_ci_red" origin/main -- .github -> no output, exit 1
Meanwhile `failure-signal.yml` writes the `ci-red` label and no workflow triggers
on it. The signal was written and never read, which is the state BACKLOG #1385's
residual 3 describes verbatim. Every test in that file passed the whole time,
because they test the RULE and nothing tested the DELIVERY.
`ci-red-report.yml` runs it on a 07:35 UTC cron plus dispatch, copying
`required-workflow-state.yml`'s shape -- the established form here for an advisory
checker. It holds NO required status context and a test asserts it never gains one:
a required check that cannot report wedges every pull request in the repository, and
gating merges on a report ABOUT other merges is circular besides.
`--warn-only` IS A MEASUREMENT, NOT A PREFERENCE. Measured 2026-09-16: 22 of the 41
open pull requests carried `ci-red`, and nothing removes the label -- the writer only
ever adds it. A cron exiting 1 on a finding would therefore be red from the day it
landed and every day after, which is decoration. So the job rules on whether it could
MEASURE, not on what it found -- the same split `quality-advisory.yml`'s `liveness`
job makes. `--warn-only` does not touch the fail-closed path: an unreadable query
still exits 2 and reds the job, and a new test pins that, because the flag becoming a
blanket "always exit 0" would have the cron report a clean repo on a query that never
ran.
Run live against MEFORORG/MessageFoundry before wiring: 22 pull requests, 3 reddened
by a merge_group run their own PR page shows as green, 4 unattributed with the
single-page truncation caveat firing correctly.
MUTATION-PROVEN. Deleting `ci-red-report.yml` -- literally the pre-fix state of
`main` -- reds both new tests. The first draft of the wiring test grepped the whole
workflow file and SURVIVED that mutation, because the new file's own header names the
script five times; it now reads `run:` bodies only. Prose about running a script
satisfying a check for running it is this item in miniature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
|
Two stale line references found while building #1242, in the two files this PR edits. Recording them here rather than fixing them, because #1242 deliberately touches neither file and correcting them from that branch would manufacture the conflict it was shaped to avoid. Both measured on 1. Resolved through 2.
One interaction worth knowing about, since it lands on this PR's header rather than its code. The |
Two measured defects in CI signalling. Neither is speculative; the command that proved each is in its commit.
1. A step promised a job name and emitted a workflow name
failure-signal.yml's step "Say which job ejected it, when the red came from the merge queue" interpolatedgithub.event.workflow_run.name— the WORKFLOW name. Every merge-queue ejection comment read "CI failed ...", which the reader already had from the label and the checks tab. The title and the behaviour disagreed.BACKLOG #1403's banner reads closed over that gap. This is its unmet acceptance.
It now names the leg and its first failing step:
It refuses to name
CI gate— the roll-up fails in every red run and its own failing step points at a leg it does not name. That is the same refusalscripts/ci/report_ci_red.pymakes, and a test now compares the two instead of trusting them to agree.The token question, resolved rather than assumed
A previous investigation left this unresolved: the jobs endpoint answers unauthenticated, but nobody had shown it answers for a scope-less
GITHUB_TOKEN. I measured it on throwaway run 35171248441, a workflow carrying this job's exact permissions block minus the new line:GITHUB_TOKEN PermissionsIssues: write,Metadata: read,PullRequests: write— no Actions entry, so the scope really wasnoneGET /actions/runs/<id>/jobswith that tokenGET /actions/runs?status=failurewith that tokenGET /actions/permissionsIt answers.
actions: readis declared anyway, alongside the existingpull-requests: writeandissues: write. The reason is in the same response:X-Accepted-Github-Permissions: actions=read. GitHub states the requirement and serves it without the scope only because this repository is public. Resting the attribution on that would make the job name vanish the day the repo turns private or GitHub enforces its own declaration — silently, with the comment still posting. That is this defect wearing the same clothes, found months later. It is a read scope and cannot modify code.Why a Python program in
env:and not inline jqThe rule has to be tested. This workflow may not check the repository out (the zizmor
dangerous-triggerssuppression rests on it running no third-party code), so it cannot import the identical rule fromreport_ci_red.py. Held inenv:it is a plain string the tests read out of the file and run — so what is exercised is the bytes that ship, not a copy beside them.It fails soft, and says which way
An ejection comment is the only record there is. 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
|| truewould have made indistinguishable. All four paths were run end to end against the shipped shell with a stubbedgh.2. A written, tested reader that nothing ran
scripts/ci/report_ci_red.pylanded 2026-09-04 withtests/test_ci_red_reader.pybeside it. No workflow invoked it.Meanwhile
failure-signal.ymlwrites theci-redlabel and nothing triggers on it. Written and never read — BACKLOG #1385's residual 3, verbatim. Every test in that file passed the whole time, because they test the rule and nothing tested the delivery.ci-red-report.ymlruns it on a 07:35 UTC cron plus dispatch, copyingrequired-workflow-state.yml's shape. It holds no required status context, and a test asserts it never gains one.--warn-onlyis a measurement, not a preferenceMeasured 2026-09-16: 22 of the 41 open pull requests carry
ci-red, and nothing removes the label — the writer only ever adds it. A cron exiting 1 on a finding would be red from the day it landed and every day after, which is decoration. So the job rules on whether it could measure, not on what it found — the same splitquality-advisory.yml'slivenessjob makes. It does not touch the fail-closed path: an unreadable query still exits 2 and reds the job, and a new test pins that.Run live before wiring: 22 pull requests, 3 reddened by a
merge_grouprun their own PR page shows as green, 4 unattributed with the single-page truncation caveat firing correctly.Mutation proofs
Eleven mutations, each reverting one claim and confirming the test that asserts it goes red, with the baseline green after restore.
ci-red-report.yml(the pre-fix state ofmain)test_a_workflow_actually_runs_this_scripttest_the_workflow_that_runs_it_holds_no_required_context--warn-onlyswallows exit 2test_warn_only_still_fails_closed_on_a_query_it_could_not_runtest_the_attribution_reads_the_jobs_of_the_run_it_is_commenting_on..._never_names_the_roll_up_when_a_real_leg_failed..._names_the_failing_job_and_steptest_a_cancelled_sibling_is_never_named_as_the_causetest_the_roll_up_the_workflow_refuses_is_the_one_the_reader_refuses..._still_posts_a_comment_and_says_what_is_missingactions: readcontents: writeThe last two are why
test_it_is_least_privilege_and_cannot_modify_codeis split in two. It used to pin the write set and the exact block in one assertion, so granting any scope re-blessed the security claim in the same keystroke.One test was mutation-proven useless and fixed. The first wiring test grepped the whole workflow file and survived deleting the invocation — the new file's own header names the script five times. It now reads
run:bodies only. Prose about running a script satisfying a check for running it is this PR in miniature.Known residual — recorded, not fixed
failure-signal.ymlwatches CI, Security and backlog-hygiene. It does not watch the three advisory crons that rule on CI's own health:required-workflow-state.yml, the newci-red-report.yml, andstalled-prs.yml. A red one reaches nobody —.github/required-contexts.txtalready records that happening for about a day.Written as prose in that file's header, not as a
not-watched:line: that register is scoped to REQUIRED workflows, and an exclusion naming a non-required one redstest_a_recorded_exclusion_names_a_real_workflow_that_really_is_required. Same precedent as CodeQL's exclusion. 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. Closing this needs a second signal whose text is true.Verification
ruff format --check .ruff check .mypy messagefoundry messagefoundry_webconsole --exclude 'messagefoundry/tray/'mypy --platform win32 messagefoundry.github/workflowspackaging/messagefoundry-webconsole/tests14 failures in
test_ci_retry_native_crash.pyandtest_nightly_notice.pyare pre-existing on this Windows box — the identical 14 fail on a cleanorigin/maincheckout of the same worktree, confirmed by stashing.No branch protection changed. No required context added. No BACKLOG number allocated — #1385 and #1403 are cited only.
🤖 Generated with Claude Code