ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_ci_parity.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Gate: the GitHub workflows and ``scripts/ci.sh`` cannot describe different CI.
5
6``scripts/ci.sh`` owns the *definition* of every gate; the workflows own only
7the *scheduling*. Each gate-bearing workflow step is therefore a thin
8``just quality::local::gate <name>`` driver, and this checker enforces that the
9two sides stay welded together in both directions:
10
111. **Every ``run:`` step in every workflow** must either invoke a registered
12 gate, or be explicitly tagged as infrastructure with a written reason::
13
14 - name: Install Unicorn + Capstone
15 run: |
16 # ci-parity: infra -- runner provisioning, runs no project check
17 sudo apt-get install -y libunicorn-dev
18
19 An untagged raw ``run:`` step is exactly how check logic grows a second
20 home, so it is rejected. An infra-tagged step may not reference anything
21 under ``scripts/`` (other than ``ci.sh`` itself), ``tests/*.sh``, or a
22 gate-ish legacy ``make`` or current ``just`` target -- otherwise "infra"
23 becomes a smuggling route for
24 the very checks this gate exists to centralise.
25
262. **Every registered gate** has to be scheduled somewhere. A gate added to
27 ``ci.sh`` and forgotten in the YAML runs locally, passes, and then never
28 runs in CI -- silent under-testing, the failure mode that motivated all of
29 this.
30
313. **"Scheduled" has to mean "can actually run."** This is the half that was
32 missing. The check above was satisfied by the gate name appearing as a
33 substring of some YAML, which is a far weaker property than it reads as: a
34 workflow whose triggers are all commented out, a step wrapped in
35 ``continue-on-error: true``, and a job behind ``if: false`` were all
36 indistinguishable from a gate running on every push. ``hil-all`` sat in
37 exactly that state -- registered, listed by ``just quality::gate::list``, parity-clean,
38 and unable to fire on any automatic trigger since its ``push:`` and
39 ``pull_request:`` keys were commented out. So a ``fast``/``slow`` gate now
40 has to reach at least one binding that is genuinely reachable, and a
41 ``manual`` gate -- which is exempt from the automatic-trigger rule by
42 definition -- still has to live in a workflow that can be dispatched or
43 scheduled, so "manual" names a real invocation route rather than a dead one.
44
454. **Only disposable runners bootstrap Just.** Ansible-managed `ra8-ci` and
46 `self-hosted` jobs consume the Just binary pinned into their runner image;
47 installing it again in each workflow is redundant and can hide image drift.
48 A non-managed job may use `setup-just`, but its exact `just-version` must
49 match `.devcontainer/Dockerfile` because the action default floats.
50
515. **Managed runners never provision their own toolchain.** Jobs targeting
52 `ra8-ci` or a `self-hosted` runner consume the environment built by Ansible.
53 Package-manager commands and runtime setup actions in those jobs are
54 rejected. The GitHub-hosted fork-PR workflow may still provision its clean
55 `ubuntu-latest` VM.
56
57None of the halves can be done alone: registering a gate without scheduling it
58fails here, scheduling an unregistered gate fails here too, and scheduling one
59somewhere it cannot run fails here as well.
60
61Why this exists: ``ci.sh`` drifted from the workflows four separate times -- a
62missing annotation gate plus a missing MISRA ratchet turned a green local run
63into a red push and got dev reverted; agents hand-copied gate bodies into
64throwaway ``/tmp`` scripts that silently stopped mirroring CI the moment a gate
65was added; an audit found 21 checks in ``firmware.yml``'s pre-commit job alone
66that were absent locally; and a hand re-sync landed to close them, which is
67evidence for this gate rather than against it. Measured across *every*
68workflow just before this checker landed, 26 distinct check invocations ran in
69CI with no local equivalent. Moving the bodies into ``ci.sh`` removes the
70duplication; this checker removes the ability to re-create it.
71
72Run::
73
74 check_ci_parity.py # scan every workflow
75 check_ci_parity.py --selftest # prove the checker still detects violations
76
77Exit 0 when the workflows and the registry agree, 1 (listing every mismatch)
78otherwise.
79"""
80
81from __future__ import annotations
82
83import argparse
84import re
85import subprocess
86import sys
87from collections.abc import Iterator
88from dataclasses import dataclass
89from pathlib import Path
90
91import yaml
92
93REPO_ROOT = Path(__file__).resolve().parents[2]
94WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows"
95CI_SH = REPO_ROOT / "scripts" / "ci.sh"
96DOCKERFILE = REPO_ROOT / ".devcontainer" / "Dockerfile"
97SETUP_JUST_PREFIX = "extractions/setup-just@"
98MANAGED_RUNNER_LABELS = frozenset({"ra8-ci", "self-hosted"})
99
100# The runner image and native HIL listener are Ansible-owned. A workflow that
101# heals either one with a package manager hides infrastructure drift and is not
102# portable to the next managed runner. Keep the GitHub-hosted fork path free to
103# provision its disposable ubuntu-latest VM.
104FORBIDDEN_MANAGED_PROVISIONING = (
105 re.compile(r"\b(?:sudo\s+)?(?:apt(?:-get)?|dnf|yum|zypper|pacman)\s+[^\n]*\binstall\b"),
106 re.compile(r"\b(?:sudo\s+)?apk\s+add\b"),
107 re.compile(r"\b(?:(?:python|python3)\s+-m\s+)?pip(?:3)?\s+install\b"),
108 re.compile(r"\buv\s+pip\s+install\b"),
109 re.compile(r"\bcargo\s+install\b"),
110 re.compile(r"\bnpm\s+install\s+(?:--global|-g)\b"),
111 re.compile(r"\bgo\s+install\b"),
112)
113FORBIDDEN_MANAGED_SETUP_ACTIONS = (
114 "actions/setup-python@",
115 "actions/setup-node@",
116 "actions/setup-java@",
117 "actions/setup-go@",
118 "ruby/setup-ruby@",
119)
120
121# A step body that calls a gate. Tolerates the line continuations and leading
122# whitespace a block scalar carries.
123GATE_CALL_RE = re.compile(r"^\s*just\s+quality::local::gate\s+\s*([A-Za-z0-9._-]+)\s*$")
124
125# The infra escape hatch. The trailing reason is mandatory: an unexplained
126# exemption is how an exemption list rots into a dumping ground.
127INFRA_MARKER_RE = re.compile(r"^\s*#\s*ci-parity:\s*infra\s*--\s*(\S.*)$")
128
129# An infra step may not run a project check. These are the shapes a smuggled
130# check takes: an in-repo script, a host-test driver, or a legacy/current task
131# runner target that can execute project checks.
132FORBIDDEN_IN_INFRA = (
133 (re.compile(r"(?<!ci\.sh)\bscripts/(?!ci\.sh)\S+"), "invokes an in-repo script under scripts/"),
134 (re.compile(r"\btests/\S+\.sh\b"), "invokes a host-test driver under tests/"),
135 (
136 re.compile(
137 r"\bmake\s+(?:-\S+\s+)*"
138 r"(test|tidy|cppcheck|coverage|mcdc|ubsan|docs|misra|check|ascii"
139 r"|version|format|bench-cache|fuzz|ci|ci-fast)\b"
140 ),
141 "invokes a gate-ish legacy task-runner target",
142 ),
143 (
144 re.compile(
145 r"\bjust\s+(?!(?:quality::local::gate)\b)"
146 r"(?:quality|checks|tests|apps|docs|tools|hil)(?:::[A-Za-z0-9_.-]+)*\b"
147 ),
148 "invokes a project-checking `just` recipe",
149 ),
150)
151
152# Minimum reason length -- "infra -- x" teaches a reader nothing.
153MIN_REASON_CHARS = 12
154
155# `--list-gates` emits "name<TAB>speed<TAB>description"; name and speed are the
156# two fields this checker needs.
157REGISTRY_MIN_FIELDS = 2
158
159# Triggers that fire without a human pressing anything. A gate reachable only
160# through `workflow_dispatch` is not part of CI; it is a button.
161AUTOMATIC_TRIGGERS = frozenset(
162 {"push", "pull_request", "pull_request_target", "schedule", "merge_group"}
163)
164
165# Triggers that can still invoke a workflow, just not on their own. A `manual`
166# speed-class gate must reach at least one of these or it cannot run at all.
167INVOCABLE_TRIGGERS = AUTOMATIC_TRIGGERS | {
168 "workflow_dispatch",
169 "repository_dispatch",
170 "workflow_call",
171}
172
173# `if:` expressions that evaluate to a constant false, disabling the job
174# outright. Matched literally: anything else is a real condition whose value
175# this checker cannot and should not try to predict. Note that an unquoted
176# `if: false` reaches us as the BOOLEAN False, not this string -- see
177# _job_disabled().
178ALWAYS_FALSE_IF = frozenset({"false", "${{ false }}", "${{false}}"})
179
180
181def load_registry() -> dict[str, str]:
182 """Return ``{gate_name: speed}`` by asking ci.sh itself.
183
184 Executing ``--list-gates`` rather than parsing the bash array keeps this
185 checker honest: the registry it validates against is the one the runner
186 will actually execute, including ci.sh's own self-check that every listed
187 name has a function behind it.
188 """
189 proc = subprocess.run( # noqa: S603 # fixed absolute protected-Bash argv, no shell
190 ["/bin/bash", "-p", str(CI_SH), "--list-gates"],
191 capture_output=True,
192 text=True,
193 cwd=REPO_ROOT,
194 check=False,
195 )
196 if proc.returncode != 0:
197 sys.stderr.write(
198 "check_ci_parity.py: `ci.sh --list-gates` failed -- the gate registry "
199 "is unreadable, so parity cannot be established.\n"
200 )
201 sys.stderr.write(proc.stderr)
202 raise SystemExit(1)
203
204 registry: dict[str, str] = {}
205 for line in proc.stdout.splitlines():
206 if not line.strip():
207 continue
208 parts = line.split("\t")
209 if len(parts) < REGISTRY_MIN_FIELDS:
210 sys.stderr.write(f"check_ci_parity.py: malformed registry row: {line!r}\n")
211 raise SystemExit(1)
212 registry[parts[0]] = parts[1]
213 if not registry:
214 sys.stderr.write(
215 "check_ci_parity.py: the gate registry is EMPTY. Refusing to report "
216 "parity against nothing.\n"
217 )
218 raise SystemExit(1)
219 return registry
220
221
222def workflow_triggers(doc: dict) -> set[str]:
223 """Return the set of trigger names declared by one workflow document.
224
225 The ``on:`` key needs care. YAML 1.1 -- which PyYAML implements -- resolves
226 a bare ``on`` to the boolean ``True``, so an unquoted ``on:`` in a GitHub
227 workflow parses as the key ``True`` rather than the string ``"on"``. Both
228 spellings are accepted here; missing that is how a checker concludes a
229 workflow has no triggers (or, worse, stops looking).
230
231 Args:
232 doc: the parsed workflow mapping.
233
234 Returns:
235 Trigger names as strings. Empty when the workflow declares none --
236 i.e. it can never run, which callers must treat as an error rather
237 than as "no constraints".
238 """
239 raw = doc.get("on", doc.get(True))
240 if isinstance(raw, str):
241 return {raw}
242 if isinstance(raw, list):
243 return {str(item) for item in raw}
244 if isinstance(raw, dict):
245 return {str(key) for key in raw}
246 return set()
247
248
249def dockerfile_just_version() -> str:
250 """Return the canonical Just release pinned by the devcontainer."""
251 text = DOCKERFILE.read_text(encoding="utf-8")
252 match = re.search(r"^ARG JUST_VERSION=(\S+)$", text, re.MULTILINE)
253 if match is None:
254 msg = f"{DOCKERFILE} does not declare ARG JUST_VERSION"
255 raise ValueError(msg)
256 return match.group(1)
257
258
259def check_setup_just_policy(workflow_dir: Path, expected: str) -> list[str]:
260 """Allow pinned setup-just steps only on non-managed runners."""
261 errors: list[str] = []
262 hosted_action_count = 0
263 workflows = sorted(list(workflow_dir.glob("*.yml")) + list(workflow_dir.glob("*.yaml")))
264 for workflow in workflows:
265 with workflow.open(encoding="utf-8") as handle:
266 doc = yaml.safe_load(handle) or {}
267 jobs = doc.get("jobs", {}) if isinstance(doc, dict) else {}
268 if not isinstance(jobs, dict):
269 continue
270 for job_name, job in jobs.items():
271 if not isinstance(job, dict):
272 continue
273 managed = bool(_runner_labels(job.get("runs-on")) & MANAGED_RUNNER_LABELS)
274 steps = job.get("steps", [])
275 if not isinstance(steps, list):
276 continue
277 for index, step in enumerate(steps, 1):
278 if not isinstance(step, dict):
279 continue
280 uses = str(step.get("uses", ""))
281 if not uses.startswith(SETUP_JUST_PREFIX):
282 continue
283 if managed:
284 errors.append(
285 f"{workflow.name}:{job_name}:step {index} uses {uses} on an "
286 "Ansible-managed runner; use the Just binary pinned into the "
287 "runner image."
288 )
289 continue
290 hosted_action_count += 1
291 inputs = step.get("with", {})
292 actual = inputs.get("just-version") if isinstance(inputs, dict) else None
293 if str(actual) != expected:
294 errors.append(
295 f"{workflow.name}:{job_name}:step {index} uses {uses} with "
296 f"just-version={actual!r}; expected Dockerfile pin {expected!r}."
297 )
298 if hosted_action_count == 0:
299 errors.append(f"no non-managed {SETUP_JUST_PREFIX} action found under {workflow_dir}")
300 return errors
301
302
303def _runner_labels(runs_on: object) -> set[str]:
304 """Normalise a job's ``runs-on`` value to a set of literal labels."""
305 if isinstance(runs_on, str):
306 return {runs_on}
307 if isinstance(runs_on, list):
308 return {str(label) for label in runs_on}
309 return set()
310
311
312def _check_managed_runner_dependencies(where: str, job: dict) -> list[str]:
313 """Reject dependency provisioning in an Ansible-managed runner job."""
314 if not (_runner_labels(job.get("runs-on")) & MANAGED_RUNNER_LABELS):
315 return []
316
317 errors: list[str] = []
318 steps = job.get("steps", [])
319 if not isinstance(steps, list):
320 return errors
321 for index, step in enumerate(steps, 1):
322 if not isinstance(step, dict):
323 continue
324 label = str(step.get("name") or f"step #{index}")
325 uses = str(step.get("uses", ""))
326 if uses.startswith(FORBIDDEN_MANAGED_SETUP_ACTIONS):
327 errors.append(
328 f"{where}, step '{label}' uses {uses}.\n"
329 f" Managed runners consume the Ansible-provisioned toolchain; they\n"
330 f" must not install a runtime inside the workflow. Put the dependency\n"
331 f" in the runner image/role and let its verification gate fail loudly."
332 )
333 body = str(step.get("run", ""))
334 for pattern in FORBIDDEN_MANAGED_PROVISIONING:
335 hit = pattern.search(body)
336 if hit:
337 errors.append(
338 f"{where}, step '{label}' provisions dependencies with\n"
339 f" {hit.group(0)!r}. Managed runners are Ansible-owned; move the\n"
340 f" dependency into the runner image/role and verify it there."
341 )
342 break
343 return errors
344
345
346@dataclass(frozen=True)
347class RunStep:
348 """One ``run:`` step, with everything needed to judge whether it can fail.
349
350 A step's body says what it would do; the last three fields say whether that
351 body's verdict reaches the outside world. All three were previously
352 ignored, which is what let "the name appears in some YAML" masquerade as
353 "the gate runs in CI".
354
355 Attributes:
356 job_name: the job key owning the step.
357 label: the step's ``name:``, or a positional fallback.
358 body: the step's ``run:`` script.
359 triggers: the owning workflow's declared trigger names.
360 job_disabled: True when the job carries a constant-false ``if:``.
361 soft: True when the step or its job cannot fail the run
362 (``continue-on-error: true``).
363 """
364
365 job_name: str
366 label: str
367 body: str
368 triggers: frozenset[str]
369 job_disabled: bool
370 soft: bool
371
372 @property
373 def runs_automatically(self) -> bool:
374 """Return True when this step executes without anyone pressing a button.
375
376 Requires an automatic trigger, an enabled job, and a step whose failure
377 actually fails the run. A ``continue-on-error`` step executes but
378 cannot enforce anything, so it does not count as a gate running.
379 """
380 return bool(self.triggers & AUTOMATIC_TRIGGERS) and not self.job_disabled and not self.soft
381
382 @property
383 def invocable(self) -> bool:
384 """Return True when this step can be reached by any route at all."""
385 return bool(self.triggers & INVOCABLE_TRIGGERS) and not self.job_disabled
386
387
388def _is_true(value: object) -> bool:
389 """Return True for a YAML value meaning boolean true, string or bool."""
390 return value is True or (isinstance(value, str) and value.strip().lower() == "true")
391
392
393def _job_disabled(job_if: object) -> bool:
394 """Return True when a job's ``if:`` is a constant false.
395
396 PyYAML resolves an unquoted ``if: false`` to the boolean ``False``, while
397 ``if: ${{ false }}`` stays a string, so both spellings have to be handled.
398 Checking only the string form would have let the plainest way of disabling
399 a job go unnoticed -- which is how this rule would have grown its own
400 blind spot.
401 """
402 if job_if is False:
403 return True
404 return isinstance(job_if, str) and job_if.strip() in ALWAYS_FALSE_IF
405
406
407def iter_run_steps(workflow: Path) -> Iterator[RunStep]:
408 """Yield one ``RunStep`` per ``run:`` step in a workflow file."""
409 with workflow.open(encoding="utf-8") as handle:
410 doc = yaml.safe_load(handle)
411 if not isinstance(doc, dict):
412 return
413 triggers = workflow_triggers(doc)
414 jobs = doc.get("jobs")
415 if not isinstance(jobs, dict):
416 return
417 for job_name, job in jobs.items():
418 if not isinstance(job, dict):
419 continue
420 steps = job.get("steps")
421 if not isinstance(steps, list):
422 continue
423 job_disabled = _job_disabled(job.get("if"))
424 job_soft = _is_true(job.get("continue-on-error"))
425 for index, step in enumerate(steps):
426 if not isinstance(step, dict):
427 continue
428 body = step.get("run")
429 if body is None:
430 continue
431 label = step.get("name") or f"step #{index + 1}"
432 yield RunStep(
433 job_name=str(job_name),
434 label=str(label),
435 body=str(body),
436 triggers=frozenset(triggers),
437 job_disabled=job_disabled,
438 soft=job_soft or _is_true(step.get("continue-on-error")),
439 )
440
441
442def classify_step(body: str) -> tuple[str, list[str], str | None]:
443 """Classify one ``run:`` body.
444
445 Returns ``(kind, gate_names, reason)`` where kind is ``"gate"``,
446 ``"infra"`` or ``"raw"``.
447 """
448 gates: list[str] = []
449 reason: str | None = None
450 other_lines: list[str] = []
451
452 for raw_line in body.splitlines():
453 line = raw_line.rstrip()
454 if not line.strip():
455 continue
456 infra = INFRA_MARKER_RE.match(line)
457 if infra:
458 reason = infra.group(1).strip()
459 continue
460 if line.lstrip().startswith("#"):
461 continue
462 call = GATE_CALL_RE.match(line)
463 if call:
464 gates.append(call.group(1))
465 continue
466 other_lines.append(line)
467
468 if gates and not other_lines:
469 return "gate", gates, reason
470 if reason is not None and not gates:
471 return "infra", [], reason
472 return "raw", gates, reason
473
474
475class Bindings:
476 """How each registered gate is bound to the workflows.
477
478 Three sets rather than one, because "named in YAML", "reachable at all"
479 and "runs on its own" are three different claims and only the last one
480 means the gate is enforcing anything on the normal path.
481 """
482
483 def __init__(self) -> None:
484 """Start with every set empty."""
485 self.named: set[str] = set()
486 self.invocable: set[str] = set()
487 self.automatic: set[str] = set()
488
489 def record(self, gate: str, step: RunStep) -> None:
490 """Record one binding of ``gate`` at ``step``, keeping the best route."""
491 self.named.add(gate)
492 if step.invocable:
493 self.invocable.add(gate)
494 if step.runs_automatically:
495 self.automatic.add(gate)
496
497
498def _check_gate_step(
499 where: str, gates: list[str], registry: dict[str, str], step: RunStep, bindings: Bindings
500) -> list[str]:
501 """Check one `--gate` step, recording how the gates it names are bound.
502
503 A workflow naming a gate the registry does not define is a typo or a
504 missing function: the step would fail at run time, having checked nothing.
505 """
506 errors: list[str] = []
507 for gate in gates:
508 if gate not in registry:
509 errors.append(
510 f"{where}\n"
511 f" runs unregistered gate '{gate}'.\n"
512 f" Add a row to RA8_GATE_REGISTRY in scripts/ci.sh and\n"
513 f" write the matching gate_{gate.replace('-', '_')}() function."
514 )
515 else:
516 bindings.record(gate, step)
517 return errors
518
519
520def reachability_errors(registry: dict[str, str], bindings: Bindings) -> list[str]:
521 """Report every gate that is named in the YAML but cannot actually enforce.
522
523 Split by speed class, because the classes make different promises:
524
525 * ``fast`` / ``slow`` claim to run in CI, so they must reach a binding on
526 an automatic trigger, in an enabled job, on a step whose failure fails
527 the run;
528 * ``manual`` claims only to be runnable on demand, so it must reach a
529 binding that something can invoke -- a dispatch or a schedule.
530
531 Args:
532 registry: ``{gate_name: speed}`` as ci.sh reports it.
533 bindings: the routes discovered while scanning the workflows.
534
535 Returns:
536 One message per gate whose binding does not back its claim.
537 """
538 errors: list[str] = []
539 for gate, speed in sorted(registry.items()):
540 if gate not in bindings.named:
541 continue # the unscheduled case is reported separately
542 if speed == "manual":
543 if gate not in bindings.invocable:
544 errors.append(
545 f"gate '{gate}' is speed=manual and is named in a workflow, but that\n"
546 f" workflow declares no trigger that can invoke it -- not even\n"
547 f" workflow_dispatch. It cannot be run by any route.\n"
548 f" Give the workflow a trigger, or delete the gate."
549 )
550 continue
551 if gate not in bindings.automatic:
552 errors.append(
553 f"gate '{gate}' is speed={speed} but no binding of it can actually run.\n"
554 f" Every step naming it is in a workflow with no automatic trigger\n"
555 f" (push / pull_request / schedule / merge_group), or in a job\n"
556 f" disabled by `if: false`, or on a step marked\n"
557 f" `continue-on-error: true` -- which executes but cannot fail\n"
558 f" anything.\n"
559 f" A gate that cannot fail CI is not scheduled, however it reads in\n"
560 f" the YAML. Restore the trigger, drop the continue-on-error, or\n"
561 f" reclassify the gate as speed=manual in RA8_GATE_REGISTRY."
562 )
563 return errors
564
565
566def _check_infra_step(where: str, body: str, reason: str | None) -> list[str]:
567 """Check one step that claims to be infrastructure rather than a check.
568
569 The claim has to be earned twice: the reason must actually say what the
570 step provisions, and the body must not invoke anything gate-shaped. A
571 check does not become infrastructure by being labelled one.
572 """
573 errors: list[str] = []
574 if reason is None or len(reason) < MIN_REASON_CHARS:
575 errors.append(
576 f"{where}\n"
577 f" is tagged `# ci-parity: infra` but the reason is missing or\n"
578 f" too terse. Write what the step provisions and why it runs no\n"
579 f" project check."
580 )
581 for pattern, why in FORBIDDEN_IN_INFRA:
582 hit = pattern.search(body)
583 if hit:
584 errors.append(
585 f"{where}\n"
586 f" is tagged `# ci-parity: infra` but {why}: {hit.group(0)!r}.\n"
587 f" A check does not become infrastructure by being labelled one.\n"
588 f" Move it into a gate function in scripts/ci.sh and call it\n"
589 f" with `just quality::local::gate <name>`."
590 )
591 return errors
592
593
594def _check_workflow(workflow: Path, registry: dict[str, str], bindings: Bindings) -> list[str]:
595 """Check one workflow's triggers, managed dependencies, and run steps."""
596 errors: list[str] = []
597 try:
598 rel: object = workflow.relative_to(REPO_ROOT)
599 except ValueError:
600 rel = workflow.name
601 with workflow.open(encoding="utf-8") as handle:
602 doc = yaml.safe_load(handle)
603 if isinstance(doc, dict) and not workflow_triggers(doc):
604 errors.append(
605 f"{rel}\n"
606 f" declares no `on:` triggers at all, so nothing in it can ever run.\n"
607 f" A workflow whose triggers were commented out looks identical to\n"
608 f" one that runs on every push -- which is exactly how a registered\n"
609 f" gate goes dormant unnoticed. Give it a trigger or delete it."
610 )
611 jobs = doc.get("jobs", {}) if isinstance(doc, dict) else {}
612 if isinstance(jobs, dict):
613 for job_name, job in jobs.items():
614 if isinstance(job, dict):
615 errors.extend(
616 _check_managed_runner_dependencies(
617 f"{rel}: job '{job_name}'",
618 job,
619 )
620 )
621 for step in iter_run_steps(workflow):
622 where = f"{rel}: job '{step.job_name}', step '{step.label}'"
623 kind, gates, reason = classify_step(step.body)
624
625 if kind == "gate":
626 errors.extend(_check_gate_step(where, gates, registry, step, bindings))
627 continue
628
629 if kind == "infra":
630 errors.extend(_check_infra_step(where, step.body, reason))
631 continue
632
633 errors.append(
634 f"{where}\n"
635 f" is a raw `run:` step. Every workflow step must either invoke a\n"
636 f" registered gate:\n"
637 f" run: just quality::local::gate <name>\n"
638 f" or declare itself infrastructure with a reason:\n"
639 f" run: |\n"
640 f" # ci-parity: infra -- <why this runs no project check>\n"
641 f" ...\n"
642 f" Inline check bodies in YAML are the drift this gate exists to stop."
643 )
644 return errors
645
646
647def check_workflows(
648 registry: dict[str, str], workflow_dir: Path = WORKFLOW_DIR
649) -> tuple[list[str], Bindings]:
650 """Scan every workflow and return ``(errors, bindings)``.
651
652 ``workflow_dir`` remains injectable so selftests exercise the exact scan
653 path that CI uses against synthetic workflow trees.
654 """
655 errors: list[str] = []
656 bindings = Bindings()
657 workflows = sorted(list(workflow_dir.glob("*.yml")) + list(workflow_dir.glob("*.yaml")))
658 if not workflows:
659 errors.append(
660 f"no workflow files found under {workflow_dir} -- "
661 "refusing to report parity against nothing"
662 )
663 return errors, bindings
664
665 for workflow in workflows:
666 errors.extend(_check_workflow(workflow, registry, bindings))
667
668 return errors, bindings
669
670
671def main() -> int:
672 """Verify the gate registry and the workflows describe the same set of gates.
673
674 Catches both halves of the drift, which fail in opposite directions: a
675 gate registered but never scheduled passes locally and never runs in CI,
676 while a workflow naming an unregistered gate is a typo or a missing
677 function. Either way the tree looks greener than it is.
678
679 Also rejects raw check bodies written inline in a workflow, since that is
680 how a second, drifting home for check logic gets created. A step that only
681 provisions the runner must declare itself as infrastructure.
682
683 Returns 0 when registry and workflows agree, 1 otherwise.
684 """
685 parser = argparse.ArgumentParser(description=__doc__)
686 parser.add_argument(
687 "--selftest",
688 action="store_true",
689 help="prove the checker still rejects the violations it is meant to catch",
690 )
691 args = parser.parse_args()
692
693 if args.selftest:
694 return selftest()
695
696 registry = load_registry()
697 errors, bindings = check_workflows(registry)
698 try:
699 errors.extend(check_setup_just_policy(WORKFLOW_DIR, dockerfile_just_version()))
700 except (OSError, ValueError) as exc:
701 errors.append(f"cannot verify setup-just pins: {exc}")
702
703 unscheduled = sorted(set(registry) - bindings.named)
704 for gate in unscheduled:
705 errors.append(
706 f"gate '{gate}' is registered in scripts/ci.sh but no workflow step\n"
707 f" ever runs it. It would pass locally and never run in CI.\n"
708 f" Add `run: just quality::local::gate {gate}` to a workflow job,\n"
709 f" or delete the gate."
710 )
711 errors.extend(reachability_errors(registry, bindings))
712
713 if errors:
714 sys.stderr.write(
715 "check_ci_parity.py: the workflows and the ci.sh gate registry disagree.\n\n"
716 )
717 for error in errors:
718 sys.stderr.write(f" {error}\n\n")
719 sys.stderr.write(f"{len(errors)} parity violation(s).\n")
720 return 1
721
722 auto = len(bindings.automatic)
723 print(
724 f"check_ci_parity.py: clean -- {len(registry)} registered gates, all scheduled "
725 f"({auto} on an automatic trigger, {len(registry) - auto} manual), "
726 f"no raw check steps in any workflow."
727 )
728 return 0
729
730
731def selftest() -> int:
732 """Verify the classifier still rejects each violation shape.
733
734 A parity guard nobody has watched fail is worth nothing, so the shapes it
735 must reject are asserted here rather than trusted.
736 """
737 cases = [
738 (
739 "raw check step",
740 "python3 scripts/checks/check_magic_numbers.py",
741 "raw",
742 ),
743 (
744 "raw multi-line step",
745 "set -e\npython3 scripts/checks/doxy_audit.py --check",
746 "raw",
747 ),
748 (
749 "gate call",
750 "just quality::local::gate ascii",
751 "gate",
752 ),
753 (
754 "gate call with trailing smuggled command",
755 "just quality::local::gate ascii\npython3 scripts/checks/cite_check.py --strict",
756 "raw",
757 ),
758 (
759 "infra step",
760 "# ci-parity: infra -- installs runner packages, runs no project check\n"
761 "sudo apt-get install -y libunicorn-dev",
762 "infra",
763 ),
764 ]
765 failures = 0
766 for label, body, expected in cases:
767 kind, _, _ = classify_step(body)
768 status = "ok" if kind == expected else "FAIL"
769 if kind != expected:
770 failures += 1
771 print(f" [{status}] {label}: classified '{kind}', expected '{expected}'")
772
773 failures += _infra_smuggling_selftest()
774 failures += _setup_just_pin_selftest()
775 failures += _managed_runner_dependencies_selftest()
776 # Imported only in the proof mode so production parity scans do not load
777 # temporary-fixture machinery. Keeping the end-to-end scan fixture in a
778 # companion module also keeps this checker focused on policy enforcement.
779 from ci_parity_scan_selftest import scan_selftest # noqa: PLC0415 -- selftest-only import
780
781 failures += scan_selftest()
782
783 if failures:
784 sys.stderr.write(f"check_ci_parity.py --selftest: {failures} case(s) failed.\n")
785 return 1
786 print("check_ci_parity.py --selftest: all cases pass.")
787 return 0
788
789
790def _infra_smuggling_selftest() -> int:
791 """Prove an infra marker cannot hide either legacy or current checks."""
792 prefix = "# ci-parity: infra -- pretends to be provisioning\n"
793 cases = (
794 ("a checker", prefix + "python3 scripts/checks/check_file_size.py"),
795 ("a Just check", prefix + "just checks::local"),
796 )
797 failures = 0
798 for label, body in cases:
799 caught = any(pattern.search(body) for pattern, _ in FORBIDDEN_IN_INFRA)
800 print(f" [{'ok' if caught else 'FAIL'}] infra step smuggling {label} is rejected")
801 if not caught:
802 failures += 1
803 return failures
804
805
806def _setup_just_pin_selftest() -> int:
807 """Prove setup-just is hosted-only, exactly pinned, and non-vacuous."""
808 import tempfile # noqa: PLC0415 # selftest-only temporary fixtures
809
810 expected = "1.40.0"
811 fixtures = (
812 ("hosted exact pin", "ubuntu-latest", "with:\n just-version: 1.40.0\n", False),
813 ("hosted missing pin", "ubuntu-latest", "", True),
814 (
815 "hosted mismatched pin",
816 "ubuntu-latest",
817 "with:\n just-version: 1.58.0\n",
818 True,
819 ),
820 )
821 failures = 0
822 for label, runs_on, with_block, must_fire in fixtures:
823 text = (
824 "name: probe\n"
825 "on: push\n"
826 "jobs:\n"
827 " probe:\n"
828 f" runs-on: {runs_on}\n"
829 " steps:\n"
830 " - uses: extractions/setup-just@v3\n"
831 f" {with_block}"
832 )
833 with tempfile.TemporaryDirectory() as tmp:
834 directory = Path(tmp)
835 (directory / "probe.yml").write_text(text, encoding="utf-8")
836 fired = bool(check_setup_just_policy(directory, expected))
837 ok = fired == must_fire
838 failures += 0 if ok else 1
839 print(f" [{'ok' if ok else 'FAIL'}] setup-just: {label}")
840
841 failures += _setup_just_managed_selftest(expected)
842
843 with tempfile.TemporaryDirectory() as tmp:
844 fired = bool(check_setup_just_policy(Path(tmp), expected))
845 ok = fired
846 failures += 0 if ok else 1
847 print(f" [{'ok' if ok else 'FAIL'}] setup-just: empty action census is rejected")
848 return failures
849
850
851def _setup_just_managed_selftest(expected: str) -> int:
852 """Prove Ansible-managed runners reject the hosted setup action."""
853 import tempfile # noqa: PLC0415 # selftest-only temporary fixtures
854
855 failures = 0
856 for label, runs_on in (
857 ("managed ra8-ci action", "ra8-ci"),
858 ("managed self-hosted action", "[self-hosted, hil, ra8d2]"),
859 ):
860 text = (
861 "name: probe\n"
862 "on: push\n"
863 "jobs:\n"
864 " hosted:\n"
865 " runs-on: ubuntu-latest\n"
866 " steps:\n"
867 " - uses: extractions/setup-just@v3\n"
868 " with:\n"
869 " just-version: 1.40.0\n"
870 " managed:\n"
871 f" runs-on: {runs_on}\n"
872 " steps:\n"
873 " - uses: extractions/setup-just@v3\n"
874 " with:\n"
875 " just-version: 1.40.0\n"
876 )
877 with tempfile.TemporaryDirectory() as tmp:
878 directory = Path(tmp)
879 (directory / "probe.yml").write_text(text, encoding="utf-8")
880 errors = check_setup_just_policy(directory, expected)
881 fired = any("Ansible-managed runner" in error for error in errors)
882 ok = fired and not any("no non-managed" in error for error in errors)
883 failures += 0 if ok else 1
884 print(f" [{'ok' if ok else 'FAIL'}] setup-just: {label} is rejected")
885
886 return failures
887
888
889def _managed_runner_dependencies_selftest() -> int:
890 """Prove only Ansible-managed jobs reject workflow-time provisioning."""
891 cases = (
892 (
893 "ra8-ci apt install",
894 {"runs-on": "ra8-ci", "steps": [{"run": "sudo apt-get install -y graphviz"}]},
895 True,
896 ),
897 (
898 "self-hosted setup-python",
899 {
900 "runs-on": ["self-hosted", "hil", "ra8d2"],
901 "steps": [{"uses": "actions/setup-python@v5"}],
902 },
903 True,
904 ),
905 (
906 "managed gate invocation",
907 {
908 "runs-on": "ra8-ci",
909 "steps": [{"run": "just quality::local::gate lint-yaml"}],
910 },
911 False,
912 ),
913 (
914 "hosted fork provisioning",
915 {
916 "runs-on": "ubuntu-latest",
917 "steps": [{"run": "sudo apt-get install -y clang-format-22"}],
918 },
919 False,
920 ),
921 )
922 failures = 0
923 for label, job, must_fire in cases:
924 fired = bool(_check_managed_runner_dependencies("probe", job))
925 ok = fired == must_fire
926 failures += 0 if ok else 1
927 print(f" [{'ok' if ok else 'FAIL'}] managed runner: {label}")
928 return failures
929
930
931if __name__ == "__main__":
932 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298