Files
Edward Langley 901f7fadc5 check-ci-graph: a defaults: run: shell: no longer disqualifies a step (the user's ruling)
Main's PR #287 added a workflow-level defaults shell, and the refusal made every
suite read as unrun. The two cases that pinned it are deleted; one shows a step
under workflow and job defaults is counted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 23:13:36 -07:00

767 lines
34 KiB
Python
Executable File

#!/usr/bin/env python3
"""pages-deploy must `needs` every other job in the workflow.
The rule that does not go stale is "every check job"; a subset is what let
README name three of seven while the rest read as ungated (improvise-x7cq).
Stating it was not enough -- a new job was added during review of PR #83 and
nothing noticed, so the rule is a gate. See context/code/tooling.md.
Second rule: every suite `scripts/checkers.sh --list` knows must run in some
CI step. ci.yml names its suites by hand, so a new `*-test.sh` ran locally and
never in CI until someone remembered to add it (improvise-dak5). Each job runs
`checkers.sh --ci <job>`, whose table lives in checkers.sh; no shell is parsed
here, and what a row runs is asked of `--would-run` (improvise-maax, st8q).
`--self-test` checks the checker: each case is a graph this must refuse.
"""
import os
import re
import subprocess
import sys
import tempfile
WORKFLOW = ".github/workflows/ci.yml"
DEPLOY = "pages-deploy"
_JOB = re.compile(r"^ ([A-Za-z0-9_-]+):$")
_JOB_OPEN = re.compile(r"^ ([A-Za-z0-9_-]+):(\s|$)")
_NEEDS = re.compile(r"^ needs:\s*\[(.*)\]\s*$")
def read_graph(text):
"""(all job names in order, the names `pages-deploy` needs).
Both come off indentation, which is what a workflow's own shape is. A job
key is two spaces; `needs:` is four INSIDE the job it belongs to, so the
current job is tracked rather than the first `needs:` taken. A job opens on
`_JOB_OPEN`, wider than `_JOB`: this reader refuses nothing, so it reads wide
(improvise-j49r).
"""
jobs, needs, current = [], None, None
in_jobs = False
for raw in text.split("\n"):
if raw.startswith("jobs:"):
in_jobs = True
continue
if not in_jobs:
continue
m = _JOB_OPEN.match(raw)
if m:
current = m.group(1)
jobs.append(current)
continue
m = _NEEDS.match(raw)
if m and current == DEPLOY:
needs = [n.strip() for n in m.group(1).split(",") if n.strip()]
return jobs, needs
def verdict(jobs, needs):
"""The complaints, as strings. Empty means the graph is sound."""
if DEPLOY not in jobs:
return [f"{DEPLOY} is not a job in this workflow"]
if needs is None:
return [f"{DEPLOY} has no `needs:` list, so nothing gates the deploy"]
expected = {j for j in jobs if j != DEPLOY}
missing = sorted(expected - set(needs))
unknown = sorted(set(needs) - expected)
out = [f"{DEPLOY} does not need `{j}`, so a failing {j} cannot stop a deploy"
for j in missing]
out += [f"{DEPLOY} needs `{j}`, which is not a job in this workflow" for j in unknown]
return out
_JOB_KEY = re.compile(r"^ ([A-Za-z_][\w-]*):(.*)$")
_STEP_START = re.compile(r"^ - ")
_STEP_KEY = re.compile(r"^ (?:- | )([A-Za-z_][\w-]*):(.*)$")
_ANY_KEY = re.compile(r"^(\s*)(?:- )?([A-Za-z_][\w-]*):(.*)$")
_CI_CALL = re.compile(r"^(?:nix shell \S+ --command )?\./scripts/checkers\.sh --ci ([\w-]+)$")
_DISQUALIFY = ("if", "continue-on-error", "shell")
_NAME = r"[A-Za-z_][\w-]*"
_TOP_KEY = re.compile(rf"^{_NAME}:(\s|$)")
_NEEDS_VALUE = re.compile(rf"^(|{_NAME}|\[\s*{_NAME}(\s*,\s*{_NAME})*\s*\])$")
_NEEDS_ITEM = re.compile(rf"^ - {_NAME}$")
_COMMENT = re.compile(r"\s#")
_UNSAFE_CHAR = re.compile("[\x00-\x08\x0b-\x1f\x7f-\x9f\u2028\u2029]")
def _readable(line, indent, in_steps, in_needs, job_keyed):
"""Whether a structural line inside `jobs:` is one this gate can read. Anything
else is refused, not parsed: context/code/tooling.md (improvise-w0j4, rjaa)."""
if indent == 2:
return bool(_JOB.match(line))
if indent == 4:
m = _JOB_KEY.match(line)
return bool(m) and (m.group(1) != "needs"
or bool(_NEEDS_VALUE.match(_inline_value(m.group(2)))))
if in_needs:
return bool(_NEEDS_ITEM.match(line))
if in_steps:
return indent in (6, 8) and bool(_STEP_KEY.match(line))
return indent > 4 and job_keyed
def _inline_value(rest):
"""A key's inline value. No comment is stripped: a line carrying one is
refused, so every matcher reads the same string (improvise-j49r)."""
return rest.strip()
_FLOW_LINE = re.compile(r"""^(\[[^\[\]{}"'#]*\]|\{[^\[\]{}"'#]*\})$""")
_BLOCK_HEADER = re.compile(r"^[|>][-+0-9]*$")
def _plain_value(value):
"""Whether a value is one of the few shapes the gate reads, all ending on their own
line; anything else is refused: context/code/tooling.md (improvise-0wn3)."""
if value == "" or _BLOCK_HEADER.match(value) or _FLOW_LINE.match(value):
return True
if value[0] in "\"'":
return not _opens_quote(value)
return value[0] not in "[]{}&*!|>%@`?,"
def _line_value(raw):
"""The value a line carries: a key's, a list item's, or the whole line's."""
k = _ANY_KEY.match(raw)
if k:
return _inline_value(k.group(3))
body = raw.strip()
return body[2:].strip() if body.startswith("- ") else body
def _opens_quote(value):
"""True when a quoted inline value is not closed on its own line."""
if value[:1] not in ("'", '"') or len(value) < 2:
return value[:1] in ("'", '"')
q, body = value[0], value[1:]
if q == "'":
return not body.replace("''", "").endswith("'")
return not re.sub(r'\\.', "", body).endswith('"')
def ci_steps(text):
"""(job, value, disqualifiers) for every step of every job, and every line
mentioning scripts/checkers.sh that is not a step's whole `run:` value.
Nothing is read as shell. A key with an inline value owns every following
line indented deeper, which is how a block scalar, a folded value, a plain
continuation or a heredoc inside one is skipped whole (improvise-st8q)."""
steps, stray, unread = [], [], []
job, in_steps, job_flags, step = None, False, set(), None
in_jobs, job_keyed = False, False
owner, owner_step = None, None
needs, if_jobs, in_needs = {}, set(), False
def close_step():
if step is not None:
steps.append(step)
for n, raw in enumerate(text.split("\n"), 1):
if _UNSAFE_CHAR.search(raw):
unread.append((n, "char"))
continue
indent = len(raw) - len(raw.lstrip())
if owner is not None and (not raw.strip() or indent > owner):
if "scripts/checkers.sh" in raw:
stray.append(n)
if owner_step is not None and raw.strip():
owner_step["multiline"] = True
continue
owner, owner_step = None, None
if not raw.strip() or raw.lstrip().startswith("#"):
continue
if indent == 0:
in_jobs = raw.startswith("jobs:")
readable = bool(_TOP_KEY.match(raw))
else:
readable = not in_jobs or indent > 8 or _readable(raw, indent, in_steps,
in_needs, job_keyed)
k = _ANY_KEY.match(raw)
shaped = _plain_value(_line_value(raw))
if _COMMENT.search(raw) or not shaped or not readable:
unread.append((n, "comment" if _COMMENT.search(raw) else
"plain" if not readable else "value"))
m = _JOB.match(raw)
if m:
close_step()
step = None
job, in_steps, job_flags, in_needs = m.group(1), False, set(), False
job_keyed = False
continue
if in_needs and re.match(r"^ - ", raw):
needs[job].add(_inline_value(raw.strip()[2:]))
continue
m = _JOB_KEY.match(raw)
if m and job is not None:
job_keyed = True
close_step()
step = None
key, value = m.group(1), _inline_value(m.group(2))
in_steps = key == "steps" and value == ""
in_needs = key == "needs" and value == ""
if key == "needs":
needs[job] = {x.strip() for x in value.strip("[]").split(",") if x.strip()}
if key == "if":
if_jobs.add(job)
if key in _DISQUALIFY:
job_flags.add(key)
for s in steps:
if s["job"] == job:
s["flags"].add("job " + key)
if in_steps and _STEP_START.match(raw):
close_step()
step = {"job": job, "run": None, "flags": {"job " + f for f in job_flags},
"line": n}
m = _STEP_KEY.match(raw) if in_steps and step is not None else None
if m:
key, value = m.group(1), _inline_value(m.group(2))
if key == "run":
step["run"] = value
if "scripts/checkers.sh" in raw and not _CI_CALL.match(value):
stray.append(n)
elif key in _DISQUALIFY:
step["flags"].add(key)
elif "scripts/checkers.sh" in raw:
stray.append(n)
elif "scripts/checkers.sh" in raw:
stray.append(n)
if k and _inline_value(k.group(3)):
owner = len(k.group(1)) + (2 if raw.lstrip().startswith("- ") else 0)
owner_step = step if m and m.group(1) == "run" else None
close_step()
for s in steps:
s["flags"] |= {f"needs {j}, which has if" for j in _needed(s["job"], needs) & if_jobs}
return steps, stray, unread
def _needed(job, needs):
"""Every job `job` needs, transitively: a skipped one skips it too (improvise-bbwa)."""
seen, todo = set(), list(needs.get(job, ()))
while todo:
j = todo.pop()
if j not in seen:
seen.add(j)
todo += needs.get(j, ())
return seen
def would_run(args):
"""What checkers.sh itself says these arguments run. Its argument rules and
its CI table are not restated here (improvise-maax)."""
out = subprocess.run(["scripts/checkers.sh", "--would-run", *args],
capture_output=True, text=True).stdout
ran, missing = set(), set()
for line in out.split("\n"):
if line.startswith("would-run: "):
ran.add(line[len("would-run: "):])
elif line.startswith("missing: "):
missing.add(line[len("missing: "):])
return ran, missing
def _unrun(s):
return (f"`{s}` is a checkers.sh suite that no CI step runs; add it to a row of "
"ci_suites in scripts/checkers.sh")
def _unread(n, kind="plain"):
if kind == "char":
return (f"ci.yml line {n} holds a control character or a line break YAML sees and "
"this gate does not (U+0085, U+2028, U+2029); remove it")
if kind == "value":
return (f"ci.yml line {n} has a value that is not one plain line (a multi-line "
"quote or flow, a nested or quoted flow, an anchor, alias or tag); spell "
"it plainly")
if kind == "comment":
return (f"ci.yml line {n} has a ` #`, which YAML may read as a comment; this gate "
"reads no comments, so put it on a line of its own")
return (f"ci.yml line {n} is a key or `needs:` value this gate cannot read as a "
"plain name, so it cannot trust the graph; spell it plainly")
def suite_verdict(suites, text):
"""The complaints: suites no counted step runs, and every step or line that
does not count, each named so the fix is obvious rather than silent."""
steps, stray, unread = ci_steps(text)
out, ran, missing = [_unread(n, c) for n, c in unread], set(), set()
for s in steps:
m = _CI_CALL.match(s["run"] or "")
if not m:
continue
if m.group(1) != s["job"]:
out.append(f"job `{s['job']}` runs `checkers.sh --ci {m.group(1)}`; "
"a job runs only its own row of the CI table")
elif s.get("multiline"):
out.append(f"job `{s['job']}`'s `checkers.sh --ci` step's run: continues "
"onto another line, so it does not count")
elif s["flags"]:
out.append(f"job `{s['job']}`'s `checkers.sh --ci` step is conditional "
f"({', '.join(sorted(s['flags']))}), so it does not count")
else:
r, mi = would_run(["--ci", s["job"]])
ran |= r
missing |= mi
out += [f"ci.yml line {n} mentions scripts/checkers.sh outside a step whose "
"whole `run:` is `./scripts/checkers.sh --ci <job>`" for n in stray]
out += [_unrun(s) for s in sorted(set(suites) - ran)]
out += [f"checkers.sh's CI table names `{s}`, which is not a suite"
for s in sorted(missing)]
return out
def read_workflow(path):
"""The workflow's text. Universal newlines fold CR and CRLF into the one break the
gate splits on; every other break YAML knows is refused (improvise-xpzh)."""
return open(path, encoding="utf8").read()
def list_suites():
"""The suites, from checkers.sh itself, so its naming rule has one home."""
out = subprocess.run(["scripts/checkers.sh", "--list"], capture_output=True,
text=True, check=True).stdout
return [s for s in out.split("\n") if s.strip()]
def _wf(*jobs):
return "jobs:\n" + "".join(jobs)
def _job(name, *steps, head=""):
return f" {name}:\n runs-on: x\n{head} steps:\n" + "".join(steps)
_BUILD = _job("build", " - run: ./scripts/checkers.sh --ci build\n")
_REQ = ["assemble-stamp", "pr-watch"]
_NO_PW = [_unrun("pr-watch")]
def _stray(n):
return (f"ci.yml line {n} mentions scripts/checkers.sh outside a step whose "
"whole `run:` is `./scripts/checkers.sh --ci <job>`")
_GATE = " gate:\n if: false\n steps:\n - run: echo\n"
_KB_RUN = " - run: ./scripts/checkers.sh --ci kb\n"
_YAML_ONLY_BREAKS = ("\x85", "\u2028", "\u2029")
_INNER_RUN = " run: ./scripts/checkers.sh --ci kb\n"
_NEEDS_GATE = ["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, which has if), "
"so it does not count"] + _NO_PW
def _unreadable(name, text, *markers, rest=(), kind="plain"):
"""A case whose workflow spells a key or a `needs:` value the gate must refuse."""
lines = text.split("\n")
at = [next(i for i, l in enumerate(lines, 1) if m in l) for m in markers]
return (name, _REQ, text, [_unread(n, kind) for n in at] + list(rest))
# (name, suites to require, workflow text, the complaints it must draw). Every
# case asks the REAL checkers.sh table, where `kb`'s row holds pr-watch.
SUITE_SELF_TEST = [
("two jobs each running their own row are accepted", _REQ,
_wf(_BUILD, _job("kb", " - run: nix shell nixpkgs#trealla --command "
"./scripts/checkers.sh --ci kb\n")), []),
_unreadable("a trailing comment on the counted run: line is refused (j49r)",
_wf(_BUILD, _job("kb", _KB_RUN.rstrip("\n") + " # the kb row\n")),
"# the kb row", rest=[_stray(9)] + _NO_PW, kind="comment"),
("a job that never calls its row leaves its suites unrun", _REQ,
_wf(_BUILD, _job("kb", " - run: echo nothing\n")), _NO_PW),
("a call inside a heredoc in a block run: does not count (st8q)", _REQ,
_wf(_BUILD, _job("kb", " - run: |\n cat >> x <<'EOF'\n"
" ./scripts/checkers.sh --ci kb\n EOF\n")),
[_stray(11)] + _NO_PW),
("a folded run: does not count (st8q)", _REQ,
_wf(_BUILD, _job("kb", " - run: >\n ./scripts/checkers.sh --ci kb\n")),
[_stray(10)] + _NO_PW),
("a plain continuation line does not count", _REQ,
_wf(_BUILD, _job("kb", " - run: echo\n ./scripts/checkers.sh --ci kb\n")),
[_stray(10)] + _NO_PW),
_unreadable("a run: line inside a multi-line quoted value: the quote is refused (6zf1)",
_wf(_BUILD, _job("kb", " - name: \"a\n"
" run: ./scripts/checkers.sh --ci kb\n b\"\n")),
'- name: "a', kind="value", rest=[_unread(11)]),
("a whole job inside a top-level block scalar does not count", _REQ,
_wf(_BUILD) + "notes: |\n kb:\n steps:\n"
" - run: ./scripts/checkers.sh --ci kb\n",
[_stray(9)] + _NO_PW),
("a step-shaped line inside a job-level block scalar does not count", _REQ,
_wf(_BUILD, _job("kb", " - run: echo\n",
head=" notes: |\n - run: ./scripts/checkers.sh --ci kb\n")),
[_stray(9)] + _NO_PW),
("a step-shaped item under a job key other than steps: does not count", _REQ,
_wf(_BUILD, " kb:\n services:\n - run: ./scripts/checkers.sh --ci kb\n"
" steps:\n - run: echo\n"),
[_stray(8)] + _NO_PW),
("the call with a continuation line does not count (1be2)", _REQ,
_wf(_BUILD, _job("kb", " - run: ./scripts/checkers.sh --ci kb\n"
" || true\n")),
["job `kb`'s `checkers.sh --ci` step's run: continues onto another line, "
"so it does not count"] + _NO_PW),
("a job that needs a job under if: does not count (bbwa)", _REQ,
_wf(_BUILD, " gate:\n if: false\n steps:\n - run: echo\n",
_job("kb", " - run: ./scripts/checkers.sh --ci kb\n",
head=" needs: [build, gate]\n")),
["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, which has if), "
"so it does not count"] + _NO_PW),
("the same, with needs: written as a list", _REQ,
_wf(_BUILD, " gate:\n if: false\n steps:\n - run: echo\n",
_job("kb", " - run: ./scripts/checkers.sh --ci kb\n",
head=" needs:\n - gate\n")),
["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, which has if), "
"so it does not count"] + _NO_PW),
("the 1be2 continuation exactly one column past the key does not count (w0j4)", _REQ,
_wf(_BUILD, _job("kb", _KB_RUN + " || true\n")),
["job `kb`'s `checkers.sh --ci` step's run: continues onto another line, "
"so it does not count"] + _NO_PW),
("control: a padded flow list is still read (w0j4)", _REQ,
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: [ gate ]\n")),
_NEEDS_GATE),
("control: a plain scalar needs: is still read (w0j4)", _REQ,
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: gate\n")),
_NEEDS_GATE),
_unreadable("a quoted name in a flow needs: is refused (w0j4)",
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=' needs: ["gate"]\n')),
'needs: ["gate"]'),
_unreadable("a quoted scalar needs: is refused (w0j4)",
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: 'gate'\n")),
"needs: 'gate'"),
_unreadable("a quoted item in a block needs: is refused (w0j4)",
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=' needs:\n - "gate"\n')),
'- "gate"'),
_unreadable("an anchored flow needs: is refused (w0j4)",
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: &d [gate]\n")),
"needs: &d"),
_unreadable("an anchored block needs: is refused (w0j4)",
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: &d\n - gate\n")),
"needs: &d"),
_unreadable("a flow needs: over two lines is refused (w0j4)",
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: [build,\n gate]\n")),
"needs: [build,"),
_unreadable("a quoted job key is refused (w0j4)",
_wf(_BUILD, _job("kb", _KB_RUN, head=' "if": false\n')),
'"if"'),
_unreadable("a quoted step key is refused (w0j4)",
_wf(_BUILD, _job("kb", " - \"if\": false\n"
" run: ./scripts/checkers.sh --ci kb\n")),
'"if"'),
_unreadable("a single-quoted continue-on-error is refused (w0j4)",
_wf(_BUILD, _job("kb", _KB_RUN, head=" 'continue-on-error': true\n")),
"'continue-on-error'"),
_unreadable("a key with a space before its colon is refused (w0j4)",
_wf(_BUILD, _job("kb", _KB_RUN, head=" if : false\n")),
"if : false"),
_unreadable("a quoted job name is refused (w0j4)",
_wf(_BUILD, ' "gate":\n if: false\n steps:\n - run: echo\n',
_job("kb", _KB_RUN)),
'"gate"',
rest=["job `build`'s `checkers.sh --ci` step is conditional (job if), "
"so it does not count", _unrun("assemble-stamp")]),
_unreadable("a quoted top-level key is refused (w0j4)",
'"defaults":\n run:\n shell: bash\n' + _wf(_BUILD, _job("kb", _KB_RUN)),
'"defaults"'),
("control: a job key's block content at indent 6 is content (rjaa)", _REQ,
_wf(_BUILD, _job("kb", _KB_RUN, head=" env:\n FOO: bar\n")), []),
_unreadable("a needed job whose keys sit at indent 6 is refused (rjaa)",
_wf(_BUILD, " gate:\n if: false\n steps:\n - run: echo\n",
_job("kb", _KB_RUN, head=" needs: gate\n")),
" if: false", " steps:", " - run: echo"),
_unreadable("a needed job whose keys sit at indent 5 is refused (rjaa)",
_wf(_BUILD, " gate:\n if: false\n steps:\n - run: echo\n",
_job("kb", _KB_RUN, head=" needs: [gate]\n")),
" if: false", " steps:", " - run: echo"),
_unreadable("a job between, with its needs: at indent 6, is refused (rjaa)",
_wf(_BUILD, _GATE,
" mid:\n needs: gate\n steps:\n - run: echo\n",
_job("kb", _KB_RUN, head=" needs: mid\n")),
" needs: gate", " steps:", " - run: echo"),
_unreadable("an explicit ? key is refused (w0j4)",
_wf(_BUILD, _job("kb", _KB_RUN, head=" ? if\n : false\n")),
"? if", ": false"),
("the same, two needs: away", _REQ,
_wf(_BUILD, " gate:\n if: false\n steps:\n - run: echo\n",
" mid:\n needs: gate\n steps:\n - run: echo\n",
_job("kb", " - run: ./scripts/checkers.sh --ci kb\n",
head=" needs: [mid]\n")),
["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, which has if), "
"so it does not count"] + _NO_PW),
("a step under if: does not count (tvte)", _REQ,
_wf(_BUILD, _job("kb", " - if: false\n run: ./scripts/checkers.sh --ci kb\n")),
["job `kb`'s `checkers.sh --ci` step is conditional (if), so it does not count"]
+ _NO_PW),
("a job under if: does not count (tvte)", _REQ,
_wf(_BUILD, _job("kb", " - run: ./scripts/checkers.sh --ci kb\n",
head=" if: false\n")),
["job `kb`'s `checkers.sh --ci` step is conditional (job if), so it does not count"]
+ _NO_PW),
("a job-level if: after its steps still disqualifies them", _REQ,
_wf(_BUILD, " kb:\n steps:\n - run: ./scripts/checkers.sh --ci kb\n"
" if: false\n"),
["job `kb`'s `checkers.sh --ci` step is conditional (job if), so it does not count"]
+ _NO_PW),
("a step under a defaults: run: shell: is counted (the user's ruling)", _REQ,
"defaults:\n run:\n shell: nix develop .#ci --command bash -e {0}\n"
+ _wf(_BUILD, _job("kb", _KB_RUN, head=" defaults:\n run:\n shell: bash\n")),
[]),
("continue-on-error does not count", _REQ,
_wf(_BUILD, _job("kb", " - run: ./scripts/checkers.sh --ci kb\n"
" continue-on-error: true\n")),
["job `kb`'s `checkers.sh --ci` step is conditional (continue-on-error), "
"so it does not count"] + _NO_PW),
("shell control flow around the call does not count", _REQ,
_wf(_BUILD, _job("kb", " - run: true || ./scripts/checkers.sh --ci kb\n")),
[_stray(9)] + _NO_PW),
("a job running another job's row is refused", _REQ,
_wf(_BUILD, _job("kb", " - run: ./scripts/checkers.sh --ci build\n")),
["job `kb` runs `checkers.sh --ci build`; a job runs only its own row of the CI table"]
+ _NO_PW),
("a bare mention in a step name is refused", _REQ,
_wf(_BUILD, _job("kb", " - name: runs scripts/checkers.sh\n"
" run: ./scripts/checkers.sh --ci kb\n")),
[_stray(9)]),
_unreadable("a job header with a trailing comment is refused (j49r)",
_wf(_BUILD, " test:\n runs-on: x\n",
" gate: # skipped on purpose\n if: false\n steps:\n - run: echo\n",
_job("kb", _KB_RUN, head=" needs: gate\n")),
"gate: # skipped", kind="comment"),
_unreadable("a closed quote followed by a comment is refused (j49r)",
_wf(_BUILD, _job("kb", _KB_RUN, head=" env:\n A: \"x\" # note\n")
+ " if: false\n", " after:\n steps:\n - run: echo \"z\"\n"),
'"x" # note', kind="comment",
rest=["job `kb`'s `checkers.sh --ci` step is conditional (job if), "
"so it does not count"] + _NO_PW),
_unreadable("a # inside a quoted value, below indent 8, is refused (j49r)",
_wf(_BUILD, _job("kb", _KB_RUN + " - uses: x\n with:\n"
" foo: \"x # y\"\n")
+ " if: false\n", " after:\n steps:\n - run: echo \"z\"\n"),
'"x # y"', kind="comment",
rest=["job `kb`'s `checkers.sh --ci` step is conditional (job if), "
"so it does not count"] + _NO_PW),
_unreadable("a trailing comment on a step is refused (j49r)",
_wf(_BUILD, _job("kb", " - uses: actions/checkout@v7 # pin\n" + _KB_RUN)),
"# pin", kind="comment"),
_unreadable("a one-line run: with a ` #` inside quotes is refused (j49r)",
_wf(_BUILD, _job("kb", " - run: echo \"a # b\"\n" + _KB_RUN)),
'"a # b"', kind="comment"),
_unreadable("a multi-line double-quoted value is refused, escaped quote and all (6zf1)",
_wf(_BUILD, " gate:\n runs-on: x\n name: \"a\n it\\\" is\n b: \"\n"
" if: false\n steps:\n - run: echo \"z\"\n",
_job("kb", _KB_RUN, head=" needs: gate\n")),
'name: "a', kind="value", rest=_NEEDS_GATE),
_unreadable("a multi-line single-quoted value is refused, doubled quote and all (6zf1)",
_wf(_BUILD, " gate:\n runs-on: x\n name: 'a\n it''s\n b: '\n"
" if: false\n steps:\n - run: echo 'z'\n",
_job("kb", _KB_RUN, head=" needs: gate\n")),
"name: 'a", kind="value", rest=_NEEDS_GATE),
_unreadable("an opening line ending in an escaped \\\" still opens a quote (6zf1)",
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: gate\n name: \"a\\\"\n"
" needs: ok\n x: y\"\n")),
'name: "a', kind="value"),
_unreadable("an opening line ending in a doubled '' still opens a quote (6zf1)",
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: gate\n name: 'it''\n"
" needs: ok\n x: y'\n")),
"name: 'it", kind="value"),
_unreadable("a flow list whose quote runs onto the next lines is refused (0wn3)",
_wf(_BUILD, _job("kb", " - name: [a, \"b\n" + _INNER_RUN + " c: x\"]\n")),
'name: [a, "b', kind="value"),
_unreadable("the same with a single-quoted string (0wn3)",
_wf(_BUILD, _job("kb", " - name: [a, 'b\n" + _INNER_RUN + " c: x']\n")),
"name: [a, 'b", kind="value"),
_unreadable("the same as a flow map (0wn3)",
_wf(_BUILD, _job("kb", " - name: {a: \"b\n" + _INNER_RUN + " c: x\"}\n")),
'name: {a: "b', kind="value"),
_unreadable("a flow list that ends in its closer with a quote still open is refused (0wn3)",
_wf(_BUILD, _job("kb", " - name: [a, \"b]\n" + _INNER_RUN + " c: x\"]\n")),
'name: [a, "b]', kind="value"),
_unreadable("a flow value opened on a line of its own is refused too (0wn3)",
_wf(_BUILD, _job("kb", " - name:\n [a, \"b\n" + _INNER_RUN
+ " c: x\"]\n")),
'[a, "b', kind="value"),
_unreadable("an anchored multi-line flow is refused (0wn3)",
_wf(_BUILD, _job("kb", " - name: &a [x, \"b\n" + _INNER_RUN + " c: x\"]\n")),
'name: &a', kind="value"),
_unreadable("a tagged multi-line flow is refused (0wn3)",
_wf(_BUILD, _job("kb", " - name: !!seq [x, \"b\n" + _INNER_RUN
+ " c: x\"]\n")),
'name: !!seq', kind="value"),
*[_unreadable(f"a U+{ord(sep):04X} inside a line is refused (xpzh)",
_wf(_BUILD, " kb:\n runs-on: x" + sep + " if: false\n steps:\n" + _KB_RUN),
sep, kind="char")
for sep in _YAML_ONLY_BREAKS],
_unreadable("a line break inside a block scalar's body is refused too (xpzh)",
_wf(_BUILD, _job("kb", _KB_RUN + " - run: |\n echo a\u2028 if: false\n")),
"echo a", kind="char"),
_unreadable("a line break inside a full-line comment is refused too (xpzh)",
_wf(_BUILD, " kb:\n runs-on: x\n # note\u2028 if: false\n steps:\n"
+ _KB_RUN), "# note", kind="char"),
("a YAML comment mentioning checkers.sh is fine", _REQ,
_wf(_BUILD, _job("kb", " # every suite goes through scripts/checkers.sh\n"
" - run: ./scripts/checkers.sh --ci kb\n")), []),
]
# (name, file bytes as text, the complaints it must draw), read back through
# read_workflow, because what a CR means depends on that read (improvise-xpzh).
FILE_SELF_TEST = [
("control: CRLF line ends are folded by the read and accepted (xpzh)",
_wf(_BUILD, _job("kb", _KB_RUN)).replace("\n", "\r\n"), []),
("control: a lone CR is a line break, as YAML reads it (xpzh)",
_wf(_BUILD, " kb:\n runs-on: x\r if: false\n steps:\n" + _KB_RUN),
["job `kb`'s `checkers.sh --ci` step is conditional (job if), so it does not count"]
+ _NO_PW),
]
# (name, workflow text, the complaints it must draw).
SELF_TEST = [
("a job nobody gates is named",
"jobs:\n test:\n x: 1\n zz-new-check:\n x: 1\n"
" pages-deploy:\n needs: [test]\n",
[f"{DEPLOY} does not need `zz-new-check`, so a failing zz-new-check cannot stop a deploy"]),
("the complete graph is accepted",
"jobs:\n test:\n x: 1\n wasm:\n x: 1\n"
" pages-deploy:\n needs: [test, wasm]\n", []),
("a needs naming a job that does not exist is caught",
"jobs:\n test:\n x: 1\n pages-deploy:\n needs: [test, ghost]\n",
[f"{DEPLOY} needs `ghost`, which is not a job in this workflow"]),
("a deploy with no needs at all is caught",
"jobs:\n test:\n x: 1\n pages-deploy:\n x: 1\n",
[f"{DEPLOY} has no `needs:` list, so nothing gates the deploy"]),
("a job header with a trailing comment is still a job (j49r)",
"jobs:\n test:\n x: 1\n gate: # skipped on purpose\n x: 1\n"
" pages-deploy:\n needs: [test]\n",
[f"{DEPLOY} does not need `gate`, so a failing gate cannot stop a deploy"]),
# `needs:` belongs to the job it is indented under. Taking the first one
# seen would read ANOTHER job's list as the deploy's and pass.
("another job's needs is not mistaken for the deploy's",
"jobs:\n test:\n x: 1\n browser:\n needs: [test]\n"
" pages-deploy:\n x: 1\n",
[f"{DEPLOY} has no `needs:` list, so nothing gates the deploy"]),
]
def self_test():
failures = []
for name, text, want in SELF_TEST:
jobs, needs = read_graph(text)
got = verdict(jobs, needs)
if got != want:
failures.append(f" {name}: expected {want}, saw {got}")
for name, suites, text, want in SUITE_SELF_TEST:
got = suite_verdict(suites, text)
if got != want:
failures.append(f" {name}: expected {want}, saw {got}")
for name, content, want in FILE_SELF_TEST:
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "ci.yml")
with open(path, "w", encoding="utf8", newline="") as f:
f.write(content)
got = suite_verdict(_REQ, read_workflow(path))
if got != want:
failures.append(f" {name}: expected {want}, saw {got}")
if failures:
print("ci-graph self-test FAILED:", file=sys.stderr)
print("\n".join(failures), file=sys.stderr)
return 1
print(f"ci-graph self-test: {len(SELF_TEST)} graphs, "
f"{len(SUITE_SELF_TEST)} suite lists and {len(FILE_SELF_TEST)} files judged")
return 0
def main():
if "--self-test" in sys.argv[1:]:
return self_test()
text = read_workflow(WORKFLOW)
jobs, needs = read_graph(text)
if len(jobs) < 2:
print(f"error: {WORKFLOW}: found {len(jobs)} jobs; the parse is wrong",
file=sys.stderr)
return 2
suites = list_suites()
if not suites:
print("error: checkers.sh --list named no suites; the listing is wrong",
file=sys.stderr)
return 2
complaints = verdict(jobs, needs) + suite_verdict(suites, text)
if complaints:
for c in complaints:
print(f"ci-graph: {c}", file=sys.stderr)
return 1
print(f"ci-graph: {DEPLOY} needs all {len(jobs) - 1} other jobs; "
f"all {len(suites)} checkers.sh suites run in CI")
return 0
if __name__ == "__main__":
sys.exit(main())