Skip to content

Commit 4026faf

Browse files
committed
ci: a required context whose workflow is disabled can never report
THE DEFECT, measured on the sibling vault repo (wshallwshall/MessageFoundry) on 2026-07-30: 10 required contexts whose workflows were ALL `disabled_manually`. A disabled workflow never dispatches, so those contexts never reported -- NO pull request could merge, and every merge there had silently been riding admin bypass. It sat that way unnoticed because the symptom presents to a human as "CI is stuck", not as "branch protection is misconfigured". Found by the ASVS session, filed unclaimed, picked up here. tests/test_required_contexts.py CANNOT see it. It resolves each required context against job names in .github/workflows/, and a disabled workflow keeps its file and its job name on disk -- so that check passes cheerfully in the healthy case AND the broken one. The property it measures ("a job with this name exists in YAML") is true either way. Same defect shape as a CI monitor polling for "nothing pending": sound about the thing it looks at, blind to the thing that can fail. Workflow `state` is server-side, so no file-based test can reach it. SCHEDULED (07:00 UTC, an hour after the nightly CI cron), not per-PR: a workflow disabled AFTER the last pull request is invisible to any per-PR check, because there is no PR to run it on. The failure arrives while the repo is idle, which is exactly when nobody is looking. It also runs on PRs touching required-contexts.txt or .github/workflows/** -- the one moment a human is changing the mapping, when a typo is cheapest to catch. Scope is REACHABILITY, not outcome: whether a check passes is CI's job; whether it can run at all is this one's. Needs only `actions: read` -- notably NOT the admin scope that reading branch protection requires. Reuses tests/_workflow_contexts.resolve rather than growing a second context->workflow mapping, and a parity test fails if that import is ever dropped. One resolver, two callers, because a second implementation is exactly the drift this repo keeps writing parity tests to catch. FAIL-CLOSED throughout: an unreadable payload, a failed API call, zero workflows returned, or zero parsed contexts all exit 2. "We could not measure" must never read as "everything is fine" -- that is the same blindness one level up. The all-unreachable case is called out BY NAME, because it is the one that cost real time: it means nothing can merge, and the only visible symptom is PRs hanging. A single disabled workflow deliberately does NOT trigger that message (a test pins that), so it cannot cry wolf. VERIFIED end-to-end against the REAL live API payload, not a hand-written fixture: live payload, all active -> exit 0 security.yml flipped to disabled -> exit 1, naming all 7 contexts it owns every workflow disabled -> exit 1, with the vault-shape diagnosis Exit codes captured WITHOUT a pipeline -- the first probe reported 0 for the failing case because $LASTEXITCODE after a pipe reflects Select-String, not python. The probe lied before the code did. Measurement DATED so it cannot age into a false claim: on 2026-07-30 MEFORORG had 19 workflows, all active, and 13/13 required contexts resolved to active workflows. The hazard is real but was not live here on that date.
1 parent 5afa27d commit 4026faf

3 files changed

Lines changed: 411 additions & 0 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: Required workflow state
2+
3+
# Every REQUIRED status check must belong to a workflow GitHub will actually run.
4+
#
5+
# THE DEFECT THIS EXISTS FOR — measured on the sibling vault repo (wshallwshall/MessageFoundry),
6+
# 2026-07-30: 10 required contexts whose workflows were ALL `disabled_manually`. A disabled workflow
7+
# never dispatches, so those contexts never reported — NO pull request could merge, and every merge
8+
# there had silently been riding admin bypass. Nobody noticed, because the symptom presents as "CI is
9+
# stuck", not as "branch protection is misconfigured".
10+
#
11+
# `tests/test_required_contexts.py` cannot see this: a disabled workflow keeps its file and job name on
12+
# disk, so resolving a context against YAML passes in the healthy AND the broken case. Workflow `state`
13+
# is server-side, so this has to be an API check.
14+
#
15+
# SCHEDULED, not per-PR, deliberately. A workflow disabled AFTER the last pull request is invisible to
16+
# any per-PR check — there is no PR to run it on. The failure arrives while the repo is idle, which is
17+
# exactly when nobody is looking.
18+
#
19+
# It checks REACHABILITY (can the context ever report?), not outcome. Whether a check passes is CI's
20+
# job; whether it is capable of running at all is this one's.
21+
on:
22+
schedule:
23+
# 07:00 UTC — an hour after the nightly CI cron, so a workflow disabled overnight is reported the
24+
# same morning rather than a day later.
25+
- cron: "0 7 * * *"
26+
workflow_dispatch:
27+
# Also on a PR that edits the required set or the workflows it points at: this is the one moment a
28+
# human is actively changing the mapping, and a typo'd context is cheapest to catch right then.
29+
pull_request:
30+
paths:
31+
- ".github/required-contexts.txt"
32+
- ".github/workflows/**"
33+
34+
permissions:
35+
contents: read
36+
37+
jobs:
38+
reachable:
39+
name: required contexts belong to active workflows
40+
runs-on: ubuntu-latest
41+
permissions:
42+
contents: read
43+
actions: read # read workflow `state`; NOT the admin scope branch protection would need
44+
steps:
45+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
46+
with:
47+
persist-credentials: false
48+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
49+
with:
50+
python-version: "3.14"
51+
- name: Install PyYAML (the resolver parses the workflow files)
52+
run: |
53+
# Hash-pinned from the CI toolchain lock, like every other scanner install (ADR 0034 §3).
54+
python -m pip install --require-hashes -r ci/locks/ci-scanners.lock
55+
- name: Reconcile required contexts against workflow state
56+
env:
57+
GH_TOKEN: ${{ github.token }}
58+
run: python scripts/ci/check_required_workflow_state.py --repo "$GITHUB_REPOSITORY"
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env python3
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
# Copyright (C) 2026 MessageFoundry Organization and contributors
4+
"""Every REQUIRED status check must belong to a workflow GitHub will actually run.
5+
6+
THE DEFECT THIS EXISTS FOR — measured, on a sibling repo, 2026-07-30. The private vault repo
7+
(``wshallwshall/MessageFoundry``) had 10 required contexts whose workflows were all
8+
``disabled_manually``. A disabled workflow never dispatches, so those contexts never reported: NO pull
9+
request could ever merge, and every merge there had silently been riding admin bypass. It sat like
10+
that long enough for nobody to notice, because the symptom presents to a human as *"CI is stuck"* —
11+
not as *"branch protection is misconfigured"*.
12+
13+
WHY THE EXISTING GUARD CANNOT SEE IT. ``tests/test_required_contexts.py`` resolves each required
14+
context against the job names in ``.github/workflows/``. A ``disabled_manually`` workflow keeps its
15+
file and its job name on disk, so that check passes cheerfully while the context can never report. The
16+
property it measures — "a job with this name exists in YAML" — is true in both the healthy case and the
17+
broken one. That is the same defect shape as a CI monitor polling for "nothing pending": sound about
18+
the thing it looks at, blind to the thing that can fail. Workflow ``state`` is server-side and
19+
invisible to any file-based test, which is why this lives in a scheduled job rather than in pytest.
20+
21+
WHY SCHEDULED AND NOT PER-PR. A workflow disabled *after* the last pull request is invisible to any
22+
per-PR check — there is no PR to run it on. The failure arrives while the repository is idle, which is
23+
exactly when nobody is looking. A daily sweep is the only shape that catches it.
24+
25+
SCOPE. This checks REACHABILITY (can the context ever report?), not outcome. Whether a check passes is
26+
CI's job; whether it is capable of running at all is this one's.
27+
28+
USAGE
29+
python scripts/ci/check_required_workflow_state.py # uses gh's auth
30+
python scripts/ci/check_required_workflow_state.py --repo owner/name
31+
python scripts/ci/check_required_workflow_state.py --states-json states.json # offline/testing
32+
"""
33+
34+
from __future__ import annotations
35+
36+
import argparse
37+
import json
38+
import subprocess
39+
import sys
40+
from pathlib import Path
41+
42+
_ROOT = Path(__file__).resolve().parents[2]
43+
44+
# Reuse the SAME resolver the doc-drift tests use, rather than re-deriving context -> workflow here.
45+
# A second implementation of that mapping is exactly the drift this repo keeps writing parity tests to
46+
# catch (ruff/bandit scan scope, the required-set prose). One resolver, two callers.
47+
sys.path.insert(0, str(_ROOT))
48+
from tests._workflow_contexts import required_contexts, resolve # noqa: E402
49+
50+
#: GitHub reports a runnable workflow as ``active``. Everything else -- ``disabled_manually``,
51+
#: ``disabled_inactivity`` (60 days idle on a fork/scheduled-only repo), ``disabled_fork`` -- means it
52+
#: will not dispatch, so a required context it owns can never report.
53+
_RUNNABLE = "active"
54+
55+
56+
def _workflow_states(repo: str | None, states_json: Path | None) -> dict[str, str]:
57+
"""``{workflow filename: state}`` for every workflow in the repo.
58+
59+
Keyed on the FILENAME rather than the display name: the display name is what ``workflow_run``
60+
matches on, but a required context resolves to a FILE, and two workflows may legitimately share a
61+
display name (this repo has two called "CodeQL").
62+
"""
63+
if states_json is not None:
64+
payload = json.loads(states_json.read_text(encoding="utf-8"))
65+
else:
66+
cmd = ["gh", "api", "--paginate"]
67+
cmd.append(
68+
f"repos/{repo}/actions/workflows" if repo else "repos/{owner}/{repo}/actions/workflows"
69+
)
70+
# B603 asks whether untrusted input reaches a subprocess. It cannot here: argv is a fixed
71+
# literal list, there is no shell, and the only variable element is `--repo`, an operator-typed
72+
# CLI argument on a CI runner — not message, config, or network data. Annotated per-line with a
73+
# reason, the posture security.yml's bandit notes require.
74+
out = subprocess.run( # noqa: S603 # nosec B603 — fixed argv, no shell, operator-supplied repo
75+
cmd, capture_output=True, text=True, timeout=120
76+
)
77+
if out.returncode != 0:
78+
raise RuntimeError(f"gh api failed ({out.returncode}): {out.stderr.strip()[:400]}")
79+
payload = json.loads(out.stdout)
80+
workflows = payload.get("workflows", payload if isinstance(payload, list) else [])
81+
return {Path(str(w.get("path", ""))).name: str(w.get("state", "")) for w in workflows}
82+
83+
84+
def main(argv: list[str] | None = None) -> int:
85+
parser = argparse.ArgumentParser(
86+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
87+
)
88+
parser.add_argument("--repo", default=None, help="owner/name; defaults to gh's current repo")
89+
parser.add_argument(
90+
"--states-json", type=Path, default=None, help="a saved API payload (testing)"
91+
)
92+
args = parser.parse_args(argv)
93+
94+
contexts = required_contexts()
95+
if not contexts:
96+
print(
97+
"::error::.github/required-contexts.txt parsed to ZERO contexts — the format changed under "
98+
"the parser. That is a broken check, not a clean sweep.",
99+
file=sys.stderr,
100+
)
101+
return 2
102+
103+
try:
104+
states = _workflow_states(args.repo, args.states_json)
105+
except (RuntimeError, json.JSONDecodeError, subprocess.SubprocessError, OSError) as exc:
106+
# FAIL CLOSED. "We could not read workflow state" must never read as "every workflow is fine" —
107+
# that is the same blindness this script exists to catch, one level up.
108+
print(
109+
f"::error::could not read workflow state ({exc!r}). Treating as a FAILURE.",
110+
file=sys.stderr,
111+
)
112+
return 2
113+
if not states:
114+
print(
115+
"::error::the API returned ZERO workflows — refusing to report success.",
116+
file=sys.stderr,
117+
)
118+
return 2
119+
120+
unreachable: list[str] = []
121+
unresolved: list[str] = []
122+
checked = 0
123+
for ctx in contexts:
124+
where = resolve(ctx)
125+
if where is None:
126+
unresolved.append(ctx)
127+
continue
128+
workflow = where[0]
129+
state = states.get(workflow)
130+
if state is None:
131+
unresolved.append(f"{ctx} (workflow {workflow} not present on the server)")
132+
continue
133+
checked += 1
134+
if state != _RUNNABLE:
135+
unreachable.append(f"{ctx} -> {workflow} [state={state}]")
136+
137+
# Liveness receipt: report what was EXAMINED. "no unreachable contexts" and "nothing was checked"
138+
# are otherwise indistinguishable from the exit code.
139+
print(f"required-workflow-state: checked {checked} of {len(contexts)} required contexts")
140+
141+
if unresolved:
142+
for item in unresolved:
143+
print(
144+
f"::error::required context resolves to no runnable workflow: {item}",
145+
file=sys.stderr,
146+
)
147+
return 1
148+
149+
if unreachable:
150+
# THE VAULT SHAPE, called out by name. When EVERY required context is unreachable, no pull
151+
# request can merge at all, and the only visible symptom is that PRs hang — which reads as
152+
# flakiness, not misconfiguration. Say the diagnosis out loud so nobody spends a day on it.
153+
if len(unreachable) == checked:
154+
print(
155+
"::error::EVERY required context belongs to a non-active workflow. NO pull request can "
156+
"merge — each required check will hang as 'Expected — waiting for status to be "
157+
"reported' forever. This presents as 'CI is stuck'; it is branch protection pointing at "
158+
"workflows GitHub will not run. Measured in this exact state on the vault repo "
159+
"(2026-07-30), where every merge had silently been riding admin bypass.",
160+
file=sys.stderr,
161+
)
162+
for item in unreachable:
163+
print(f"::error::{item}", file=sys.stderr)
164+
print(
165+
"\nRe-enable the workflow (`gh workflow enable <file>`), or remove the context from branch "
166+
"protection AND .github/required-contexts.txt — deliberately, in a reviewed diff.",
167+
file=sys.stderr,
168+
)
169+
return 1
170+
171+
print(f"required-workflow-state: all {checked} required contexts belong to active workflows.")
172+
return 0
173+
174+
175+
if __name__ == "__main__":
176+
raise SystemExit(main())

0 commit comments

Comments
 (0)