4"""Gate: the GitHub workflows and ``scripts/ci.sh`` cannot describe different CI.
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:
111. **Every ``run:`` step in every workflow** must either invoke a registered
12 gate, or be explicitly tagged as infrastructure with a written reason::
14 - name: Install Unicorn + Capstone
16 # ci-parity: infra -- runner provisioning, runs no project check
17 sudo apt-get install -y libunicorn-dev
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.
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
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.
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.
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
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.
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.
74 check_ci_parity.py # scan every workflow
75 check_ci_parity.py --selftest # prove the checker still detects violations
77Exit 0 when the workflows and the registry agree, 1 (listing every mismatch)
81from __future__
import annotations
87from collections.abc
import Iterator
88from dataclasses
import dataclass
89from pathlib
import Path
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"})
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"),
113FORBIDDEN_MANAGED_SETUP_ACTIONS = (
114 "actions/setup-python@",
115 "actions/setup-node@",
116 "actions/setup-java@",
123GATE_CALL_RE = re.compile(
r"^\s*just\s+quality::local::gate\s+\s*([A-Za-z0-9._-]+)\s*$")
127INFRA_MARKER_RE = re.compile(
r"^\s*#\s*ci-parity:\s*infra\s*--\s*(\S.*)$")
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/"),
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"
141 "invokes a gate-ish legacy task-runner target",
145 r"\bjust\s+(?!(?:quality::local::gate)\b)"
146 r"(?:quality|checks|tests|apps|docs|tools|hil)(?:::[A-Za-z0-9_.-]+)*\b"
148 "invokes a project-checking `just` recipe",
157REGISTRY_MIN_FIELDS = 2
161AUTOMATIC_TRIGGERS = frozenset(
162 {
"push",
"pull_request",
"pull_request_target",
"schedule",
"merge_group"}
167INVOCABLE_TRIGGERS = AUTOMATIC_TRIGGERS | {
169 "repository_dispatch",
178ALWAYS_FALSE_IF = frozenset({
"false",
"${{ false }}",
"${{false}}"})
181def load_registry() -> dict[str, str]:
182 """Return ``{gate_name: speed}`` by asking ci.sh itself.
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.
189 proc = subprocess.run(
190 [
"/bin/bash",
"-p", str(CI_SH),
"--list-gates"],
196 if proc.returncode != 0:
198 "check_ci_parity.py: `ci.sh --list-gates` failed -- the gate registry "
199 "is unreadable, so parity cannot be established.\n"
201 sys.stderr.write(proc.stderr)
204 registry: dict[str, str] = {}
205 for line
in proc.stdout.splitlines():
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")
212 registry[parts[0]] = parts[1]
215 "check_ci_parity.py: the gate registry is EMPTY. Refusing to report "
216 "parity against nothing.\n"
222def workflow_triggers(doc: dict) -> set[str]:
223 """Return the set of trigger names declared by one workflow document.
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).
232 doc: the parsed workflow mapping.
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".
239 raw = doc.get(
"on", doc.get(
True))
240 if isinstance(raw, str):
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}
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)
254 msg = f
"{DOCKERFILE} does not declare ARG JUST_VERSION"
255 raise ValueError(msg)
256 return match.group(1)
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):
270 for job_name, job
in jobs.items():
271 if not isinstance(job, dict):
273 managed = bool(_runner_labels(job.get(
"runs-on")) & MANAGED_RUNNER_LABELS)
274 steps = job.get(
"steps", [])
275 if not isinstance(steps, list):
277 for index, step
in enumerate(steps, 1):
278 if not isinstance(step, dict):
280 uses = str(step.get(
"uses",
""))
281 if not uses.startswith(SETUP_JUST_PREFIX):
285 f
"{workflow.name}:{job_name}:step {index} uses {uses} on an "
286 "Ansible-managed runner; use the Just binary pinned into the "
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:
295 f
"{workflow.name}:{job_name}:step {index} uses {uses} with "
296 f
"just-version={actual!r}; expected Dockerfile pin {expected!r}."
298 if hosted_action_count == 0:
299 errors.append(f
"no non-managed {SETUP_JUST_PREFIX} action found under {workflow_dir}")
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):
307 if isinstance(runs_on, list):
308 return {str(label)
for label
in runs_on}
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):
317 errors: list[str] = []
318 steps = job.get(
"steps", [])
319 if not isinstance(steps, list):
321 for index, step
in enumerate(steps, 1):
322 if not isinstance(step, dict):
324 label = str(step.get(
"name")
or f
"step #{index}")
325 uses = str(step.get(
"uses",
""))
326 if uses.startswith(FORBIDDEN_MANAGED_SETUP_ACTIONS):
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."
333 body = str(step.get(
"run",
""))
334 for pattern
in FORBIDDEN_MANAGED_PROVISIONING:
335 hit = pattern.search(body)
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."
346@dataclass(frozen=True)
348 """One ``run:`` step, with everything needed to judge whether it can fail.
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".
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``).
368 triggers: frozenset[str]
373 def runs_automatically(self) -> bool:
374 """Return True when this step executes without anyone pressing a button.
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.
380 return bool(self.triggers & AUTOMATIC_TRIGGERS)
and not self.job_disabled
and not self.soft
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
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")
393def _job_disabled(job_if: object) -> bool:
394 """Return True when a job's ``if:`` is a constant false.
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
404 return isinstance(job_if, str)
and job_if.strip()
in ALWAYS_FALSE_IF
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):
413 triggers = workflow_triggers(doc)
414 jobs = doc.get(
"jobs")
415 if not isinstance(jobs, dict):
417 for job_name, job
in jobs.items():
418 if not isinstance(job, dict):
420 steps = job.get(
"steps")
421 if not isinstance(steps, list):
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):
428 body = step.get(
"run")
431 label = step.get(
"name")
or f
"step #{index + 1}"
433 job_name=str(job_name),
436 triggers=frozenset(triggers),
437 job_disabled=job_disabled,
438 soft=job_soft
or _is_true(step.get(
"continue-on-error")),
442def classify_step(body: str) -> tuple[str, list[str], str |
None]:
443 """Classify one ``run:`` body.
445 Returns ``(kind, gate_names, reason)`` where kind is ``"gate"``,
446 ``"infra"`` or ``"raw"``.
448 gates: list[str] = []
449 reason: str |
None =
None
450 other_lines: list[str] = []
452 for raw_line
in body.splitlines():
453 line = raw_line.rstrip()
456 infra = INFRA_MARKER_RE.match(line)
458 reason = infra.group(1).strip()
460 if line.lstrip().startswith(
"#"):
462 call = GATE_CALL_RE.match(line)
464 gates.append(call.group(1))
466 other_lines.append(line)
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
476 """How each registered gate is bound to the workflows.
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.
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()
489 def record(self, gate: str, step: RunStep) ->
None:
490 """Record one binding of ``gate`` at ``step``, keeping the best route."""
493 self.invocable.add(gate)
494 if step.runs_automatically:
495 self.automatic.add(gate)
499 where: str, gates: list[str], registry: dict[str, str], step: RunStep, bindings: Bindings
501 """Check one `--gate` step, recording how the gates it names are bound.
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.
506 errors: list[str] = []
508 if gate
not in registry:
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."
516 bindings.record(gate, step)
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.
523 Split by speed class, because the classes make different promises:
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
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.
532 registry: ``{gate_name: speed}`` as ci.sh reports it.
533 bindings: the routes discovered while scanning the workflows.
536 One message per gate whose binding does not back its claim.
538 errors: list[str] = []
539 for gate, speed
in sorted(registry.items()):
540 if gate
not in bindings.named:
542 if speed ==
"manual":
543 if gate
not in bindings.invocable:
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."
551 if gate
not in bindings.automatic:
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"
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."
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.
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.
573 errors: list[str] = []
574 if reason
is None or len(reason) < MIN_REASON_CHARS:
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"
581 for pattern, why
in FORBIDDEN_IN_INFRA:
582 hit = pattern.search(body)
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>`."
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] = []
598 rel: object = workflow.relative_to(REPO_ROOT)
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):
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."
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):
616 _check_managed_runner_dependencies(
617 f
"{rel}: job '{job_name}'",
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)
626 errors.extend(_check_gate_step(where, gates, registry, step, bindings))
630 errors.extend(_check_infra_step(where, step.body, reason))
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"
640 f
" # ci-parity: infra -- <why this runs no project check>\n"
642 f
" Inline check bodies in YAML are the drift this gate exists to stop."
648 registry: dict[str, str], workflow_dir: Path = WORKFLOW_DIR
649) -> tuple[list[str], Bindings]:
650 """Scan every workflow and return ``(errors, bindings)``.
652 ``workflow_dir`` remains injectable so selftests exercise the exact scan
653 path that CI uses against synthetic workflow trees.
655 errors: list[str] = []
656 bindings = Bindings()
657 workflows = sorted(list(workflow_dir.glob(
"*.yml")) + list(workflow_dir.glob(
"*.yaml")))
660 f
"no workflow files found under {workflow_dir} -- "
661 "refusing to report parity against nothing"
663 return errors, bindings
665 for workflow
in workflows:
666 errors.extend(_check_workflow(workflow, registry, bindings))
668 return errors, bindings
672 """Verify the gate registry and the workflows describe the same set of gates.
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.
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.
683 Returns 0 when registry and workflows agree, 1 otherwise.
685 parser = argparse.ArgumentParser(description=__doc__)
689 help=
"prove the checker still rejects the violations it is meant to catch",
691 args = parser.parse_args()
696 registry = load_registry()
697 errors, bindings = check_workflows(registry)
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}")
703 unscheduled = sorted(set(registry) - bindings.named)
704 for gate
in unscheduled:
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."
711 errors.extend(reachability_errors(registry, bindings))
715 "check_ci_parity.py: the workflows and the ci.sh gate registry disagree.\n\n"
718 sys.stderr.write(f
" {error}\n\n")
719 sys.stderr.write(f
"{len(errors)} parity violation(s).\n")
722 auto = len(bindings.automatic)
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."
731def selftest() -> int:
732 """Verify the classifier still rejects each violation shape.
734 A parity guard nobody has watched fail is worth nothing, so the shapes it
735 must reject are asserted here rather than trusted.
740 "python3 scripts/checks/check_magic_numbers.py",
744 "raw multi-line step",
745 "set -e\npython3 scripts/checks/doxy_audit.py --check",
750 "just quality::local::gate ascii",
754 "gate call with trailing smuggled command",
755 "just quality::local::gate ascii\npython3 scripts/checks/cite_check.py --strict",
760 "# ci-parity: infra -- installs runner packages, runs no project check\n"
761 "sudo apt-get install -y libunicorn-dev",
766 for label, body, expected
in cases:
767 kind, _, _ = classify_step(body)
768 status =
"ok" if kind == expected
else "FAIL"
771 print(f
" [{status}] {label}: classified '{kind}', expected '{expected}'")
773 failures += _infra_smuggling_selftest()
774 failures += _setup_just_pin_selftest()
775 failures += _managed_runner_dependencies_selftest()
779 from ci_parity_scan_selftest
import scan_selftest
781 failures += scan_selftest()
784 sys.stderr.write(f
"check_ci_parity.py --selftest: {failures} case(s) failed.\n")
786 print(
"check_ci_parity.py --selftest: all cases pass.")
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"
794 (
"a checker", prefix +
"python3 scripts/checks/check_file_size.py"),
795 (
"a Just check", prefix +
"just checks::local"),
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")
806def _setup_just_pin_selftest() -> int:
807 """Prove setup-just is hosted-only, exactly pinned, and non-vacuous."""
812 (
"hosted exact pin",
"ubuntu-latest",
"with:\n just-version: 1.40.0\n",
False),
813 (
"hosted missing pin",
"ubuntu-latest",
"",
True),
815 "hosted mismatched pin",
817 "with:\n just-version: 1.58.0\n",
822 for label, runs_on, with_block, must_fire
in fixtures:
828 f
" runs-on: {runs_on}\n"
830 " - uses: extractions/setup-just@v3\n"
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}")
841 failures += _setup_just_managed_selftest(expected)
843 with tempfile.TemporaryDirectory()
as tmp:
844 fired = bool(check_setup_just_policy(Path(tmp), expected))
846 failures += 0
if ok
else 1
847 print(f
" [{'ok' if ok else 'FAIL'}] setup-just: empty action census is rejected")
851def _setup_just_managed_selftest(expected: str) -> int:
852 """Prove Ansible-managed runners reject the hosted setup action."""
856 for label, runs_on
in (
857 (
"managed ra8-ci action",
"ra8-ci"),
858 (
"managed self-hosted action",
"[self-hosted, hil, ra8d2]"),
865 " runs-on: ubuntu-latest\n"
867 " - uses: extractions/setup-just@v3\n"
869 " just-version: 1.40.0\n"
871 f
" runs-on: {runs_on}\n"
873 " - uses: extractions/setup-just@v3\n"
875 " just-version: 1.40.0\n"
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")
889def _managed_runner_dependencies_selftest() -> int:
890 """Prove only Ansible-managed jobs reject workflow-time provisioning."""
893 "ra8-ci apt install",
894 {
"runs-on":
"ra8-ci",
"steps": [{
"run":
"sudo apt-get install -y graphviz"}]},
898 "self-hosted setup-python",
900 "runs-on": [
"self-hosted",
"hil",
"ra8d2"],
901 "steps": [{
"uses":
"actions/setup-python@v5"}],
906 "managed gate invocation",
909 "steps": [{
"run":
"just quality::local::gate lint-yaml"}],
914 "hosted fork provisioning",
916 "runs-on":
"ubuntu-latest",
917 "steps": [{
"run":
"sudo apt-get install -y clang-format-22"}],
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}")
931if __name__ ==
"__main__":
932 raise SystemExit(
main())
void main(void)
The application entry point Reset_Handler hands control to.