#!/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 `, 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 `" 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 `") _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())