1#!/usr/bin/env python32"""Validate the executable semantics of CI's required complete demo gate.34The gate used to be a single serial job, and this checker pinned that job byte5for byte: the exact step list, the exact order, the exact run bodies. That6stopped anyone weakening the gate, but it equally stopped anyone speeding it7up, so a documentation pull request paid thirty minutes for checks it could not8affect.910The gate is now several parallel legs behind one required summary job. This11checker therefore asserts *invariants* rather than byte equality:1213 * every leg the gate needs exists, runs the stage it claims to run, and14 cannot suppress its own failure;15 * the legs that must always run carry no `if:` at all;16 * the legs that may be skipped carry exactly the audited docs-only condition17 and nothing else;18 * the summary job depends on every other job in the workflow, so a new job19 cannot be added outside the gate;20 * the summary job fails unless each dependency succeeded or was a skip the21 docs-only scope is allowed to make;22 * the change-scope job fails closed on anything but a provably docs-only diff;23 * no step can poison the toolchain or mask a failure.2425Coverage of the individual checks inside each stage is proven separately, by26executing the gate against stubbed tools in scripts/tests/demo-gate-stages.sh.27Static structure here, dynamic coverage there.28"""2930from __future__ import annotations3132import argparse33import re34import sys35from pathlib import Path363738CHECKOUT_ACTION = "actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8"39CACHE_DIGEST = "0400d5f644dc74513175e3cd8d07132dd4860809"40CACHE_RESTORE_ACTION = f"actions/cache/restore@{CACHE_DIGEST}"41CACHE_SAVE_ACTION = f"actions/cache/save@{CACHE_DIGEST}"42COMBINED_CACHE_ACTION = f"actions/cache@{CACHE_DIGEST}"43COMBINED_CACHE_ALLOWED_PATHS = frozenset({"~/.cargo/bin/cargo-zigbuild"})4445# A cache entry is scoped to the branch that writes it, so writing from pull46# requests gave every open pull request its own multi-gigabyte set and evicted47# the entries the legs depend on. Saves are therefore gated to pushes. These are48# the only step-level conditions the policy permits at all: a conditional step49# is otherwise a way to silently stop running a check.50PUSH_ONLY_SAVE = "github.event_name == 'push' && steps.cargo-cache.outputs.cache-hit != 'true'"51ALLOWED_STEP_CONDITIONS = frozenset(52 {53 PUSH_ONLY_SAVE,54 # Matrix legs that share one cache entry nominate a single writer, so no55 # two concurrent legs race to reserve the same key.56 f"{PUSH_ONLY_SAVE} && matrix.save == 'true'",57 }58)5960GATE_SCRIPT = "./scripts/verify-demo-build.sh"61LINUX_CROSS_BUILD_GATE = "./scripts/run-linux-cross-build-gate.sh"62DEPENDENCY_SCRIPT = "./scripts/install-linux-build-deps.sh"63SUMMARY_JOB = "demo-gate"64SUMMARY_JOB_NAME = "Complete demo gate"65SCOPE_JOB = "scope"66SCOPE_CONDITION = "needs.scope.outputs.code == 'true'"67REQUIRED_RUNNER = "ubuntu-24.04"6869# Legs that run the repository gate, and the stage each one must run. The gate70# script's own stage list is checked against this in verify_gate_script.71GATE_LEG_STAGES = {72 "quick-checks": "quick",73 "repo-tests": "repo-tests",74 "native-tests": "native",75 "cross-build": "cross",76 "gstreamer-gate": "gstreamer",77 "loopback": "loopback",78 "release-containment": "containment",79}8081# Legs that must run for every change, documentation included. The website,82# docs, and README claim contracts are exercised by the repository test suite,83# so these legs are what keep a docs-only pull request honest.84ALWAYS_RUN_JOBS = frozenset({SCOPE_JOB, "quick-checks", "repo-tests", "decky-gate"})8586# Legs the audited docs-only scope may skip. Must match the summary job body.87SKIPPABLE_JOBS = frozenset(88 {89 "native-tests",90 "cross-build",91 "gstreamer-gate",92 "loopback",93 "release-containment",94 "portal-target-matrix",95 }96)9798# Tokens that would let a step or the gate script pass while something inside99# it failed, or would let a step swap out the toolchain the gate measures.100FORBIDDEN_RUN_TOKENS = (101 "|| true",102 "|| :",103 "set +e",104 "exec true",105 "trap ",106 "RUSTC_WRAPPER",107 "continue-on-error",108 ">~/.cargo/env",109 ">> ~/.cargo/env",110 ">>~/.cargo/env",111)112113# The change-scope job is security logic: every path that cannot prove the diff114# is documentation must classify the change as code. Require each of those115# fail-closed branches to be present verbatim.116REQUIRED_SCOPE_BRANCHES = (117 "if [[ \"$EVENT_NAME\" != 'pull_request' ]]; then",118 "scope=full (event is not a pull request)",119 "scope=full (could not enumerate the pull request diff)",120 "scope=full (empty diff listing)",121)122123# The path allowlist decides which diffs may skip the compile-heavy legs, so it124# is pinned exactly rather than by substring. Widening it by adding another case125# arm is the change this is here to catch; indentation may still change freely.126REQUIRED_SCOPE_CASE = (127 'case "$changed_path" in',128 "docs/* | website/* | *.md | LICENSE) ;;",129 "*)",130 "code=true",131 "printf 'code_path=%s\\n' \"$changed_path\"",132 ";;",133 "esac",134)135136GATE_SCRIPT_PREFIX = """\137#!/usr/bin/env bash138set -euo pipefail139140script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"141repo_dir="$(cd -- "$script_dir/.." && pwd -P)"142cd "$repo_dir"143"""144145DIGEST_PINNED = re.compile(r"[A-Za-z0-9._/-]+@[0-9a-f]{40}")146147148class PolicyError(ValueError):149 """The workflow does not meet the mandatory complete demo gate policy."""150151152def _indent(line: str) -> int:153 return len(line) - len(line.lstrip(" "))154155156def _is_content(line: str) -> bool:157 stripped = line.strip()158 return bool(stripped) and not stripped.startswith("#")159160161def _scalar(value: str) -> str:162 value = value.strip()163 if value[:1] in "\"'" and len(value) >= 2:164 quote = value[0]165 end = value.find(quote, 1)166 if end != -1:167 return value[1:end]168 value = re.sub(r"\s+#.*$", "", value).strip()169 if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":170 return value[1:-1]171 return value172173174def _flow_sequence(value: str) -> list[str]:175 inner = value.strip()[1:-1].strip()176 if not inner:177 return []178 return [_scalar(item) for item in inner.split(",")]179180181class Parser:182 """A strict parser for the YAML subset these workflows use.183184 Anything it does not recognise raises PolicyError. An unparseable workflow185 is a rejected workflow, which keeps the checker fail-closed instead of186 silently ignoring a construct it does not understand.187 """188189 def __init__(self, text: str) -> None:190 self.lines = text.splitlines()191192 def parse(self) -> dict:193 value, index = self._block(0, 0)194 index = self._next_content(index)195 if index != len(self.lines):196 raise PolicyError(f"cannot parse workflow line: {self.lines[index]}")197 if not isinstance(value, dict):198 raise PolicyError("workflow must be a mapping at the top level")199 return value200201 def _next_content(self, index: int) -> int:202 while index < len(self.lines) and not _is_content(self.lines[index]):203 index += 1204 return index205206 def _block(self, index: int, indent: int) -> tuple[object, int]:207 index = self._next_content(index)208 if index >= len(self.lines) or _indent(self.lines[index]) < indent:209 return None, index210 if self.lines[index].lstrip(" ").startswith("- "):211 return self._sequence(index, indent)212 return self._mapping_from(None, index, indent)213214 def _sequence(self, index: int, indent: int) -> tuple[list, int]:215 items: list[object] = []216 while True:217 index = self._next_content(index)218 if index >= len(self.lines):219 break220 line = self.lines[index]221 if _indent(line) != indent or not line.lstrip(" ").startswith("- "):222 break223 rest = line.lstrip(" ")[2:]224 if ":" in rest:225 entry, index = self._mapping_from(rest, index + 1, indent + 2)226 items.append(entry)227 else:228 items.append(_scalar(rest))229 index += 1230 return items, index231232 def _mapping_from(233 self, first: str | None, index: int, indent: int234 ) -> tuple[dict, int]:235 entries: dict[str, object] = {}236237 def add(key: str, value: object) -> None:238 if key in entries:239 raise PolicyError(f"duplicate key {key!r} in the workflow")240 entries[key] = value241242 if first is not None:243 key, value, index = self._entry(first, index, indent)244 add(key, value)245 while True:246 index = self._next_content(index)247 if index >= len(self.lines):248 break249 line = self.lines[index]250 if _indent(line) < indent:251 break252 if _indent(line) > indent:253 raise PolicyError(f"unexpected indentation: {line}")254 if line.lstrip(" ").startswith("- "):255 break256 key, value, index = self._entry(line.lstrip(" "), index + 1, indent)257 add(key, value)258 return entries, index259260 def _entry(self, text: str, index: int, indent: int) -> tuple[str, object, int]:261 match = re.fullmatch(262 r"(\"[^\"]+\"|'[^']+'|[A-Za-z0-9_.-]+):(?:\s+(.*))?", text263 )264 if not match:265 raise PolicyError(f"cannot parse workflow field: {text}")266 key = _scalar(match.group(1))267 raw = (match.group(2) or "").strip()268 if raw in ("|", "|-", ">", ">-"):269 body, index = self._block_scalar(index, indent, raw)270 return key, body, index271 if raw.startswith("["):272 return key, _flow_sequence(raw), index273 if raw:274 return key, _scalar(raw), index275 value, index = self._block(index, indent + 2)276 return key, value, index277278 def _block_scalar(self, index: int, indent: int, style: str) -> tuple[str, int]:279 body: list[str] = []280 content_indent: int | None = None281 while index < len(self.lines):282 line = self.lines[index]283 if not line.strip():284 body.append("")285 index += 1286 continue287 if _indent(line) <= indent:288 break289 if content_indent is None:290 content_indent = _indent(line)291 if _indent(line) < content_indent:292 break293 body.append(line[content_indent:])294 index += 1295 text = "\n".join(body).rstrip()296 if style.endswith("-"):297 return text, index298 return text + "\n", index299300301def _require_mapping(value: object, what: str) -> dict:302 if not isinstance(value, dict):303 raise PolicyError(f"{what} must be a mapping")304 return value305306307def _steps(job: dict, name: str) -> list[dict]:308 steps = job.get("steps")309 if not isinstance(steps, list) or not steps:310 raise PolicyError(f"jobs.{name} must define a non-empty steps list")311 for step in steps:312 if not isinstance(step, dict):313 raise PolicyError(f"jobs.{name} contains a step that is not a mapping")314 return steps315316317def _run_body(step: dict) -> str:318 run = step.get("run")319 return run if isinstance(run, str) else ""320321322def _check_no_failure_suppression(job_name: str, job: dict) -> None:323 if "continue-on-error" in job:324 raise PolicyError(f"jobs.{job_name} must not suppress failures")325 if "defaults" in job:326 raise PolicyError(327 f"jobs.{job_name} must not override run defaults or shell failure behavior"328 )329 for step in _steps(job, job_name):330 if "continue-on-error" in step:331 raise PolicyError(f"jobs.{job_name} has a step that suppresses failures")332 if "if" in step:333 # Only a push-gated cache save may be conditional. Anything else334 # would be a way to stop running a check without saying so.335 if step.get("uses") != CACHE_SAVE_ACTION:336 raise PolicyError(337 f"jobs.{job_name} has a conditionally disabled step; "338 "gate a whole leg instead"339 )340 if step["if"] not in ALLOWED_STEP_CONDITIONS:341 raise PolicyError(342 f"jobs.{job_name} saves its cache under an unapproved "343 f"condition: {step['if']!r}"344 )345 body = _run_body(step)346 for token in FORBIDDEN_RUN_TOKENS:347 if token in body:348 raise PolicyError(349 f"jobs.{job_name} has a step containing the forbidden token {token!r}"350 )351 uses = step.get("uses")352 if uses is None:353 continue354 if not isinstance(uses, str) or not DIGEST_PINNED.fullmatch(uses):355 raise PolicyError(356 f"jobs.{job_name} uses an action that is not digest-pinned: {uses!r}"357 )358 if uses.startswith("actions/checkout@"):359 if uses != CHECKOUT_ACTION:360 raise PolicyError(361 "checkout action is not digest-pinned to the policy contract"362 )363 if "with" in step:364 raise PolicyError(f"jobs.{job_name} overrides the checked-out ref")365366367def _validate_caches(job_name: str, job: dict) -> None:368 steps = _steps(job, job_name)369 restores = [step for step in steps if step.get("uses") == CACHE_RESTORE_ACTION]370 saves = [step for step in steps if step.get("uses") == CACHE_SAVE_ACTION]371 combined = [step for step in steps if step.get("uses") == COMBINED_CACHE_ACTION]372373 # The combined action reads and writes from every branch, so it stays374 # available only for the one entry small enough not to matter.375 for step in combined:376 with_fields = step.get("with")377 path = with_fields.get("path", "") if isinstance(with_fields, dict) else ""378 if path.strip() not in COMBINED_CACHE_ALLOWED_PATHS:379 raise PolicyError(380 f"jobs.{job_name} may only read-write cache the pinned "381 f"cargo-zigbuild binary, not {path.strip()!r}"382 )383384 # Without this an unconditional save would quietly restore the per-pull-385 # request cache sets that exhausted the repository's cache ceiling.386 for step in saves:387 if "if" not in step:388 raise PolicyError(389 f"jobs.{job_name} saves its Cargo cache unconditionally; "390 "saves must be gated to pushes"391 )392393 if len(restores) > 1 or len(saves) > 1:394 raise PolicyError(395 f"jobs.{job_name} must restore and save at most one Cargo cache"396 )397 if len(restores) != len(saves):398 raise PolicyError(399 f"jobs.{job_name} must pair its Cargo cache restore with a save so "400 "main keeps a fresh entry"401 )402 if not restores:403 return404405 restore_with = restores[0].get("with")406 save_with = saves[0].get("with")407 if not isinstance(restore_with, dict) or not isinstance(save_with, dict):408 raise PolicyError(f"jobs.{job_name} Cargo cache steps need a with mapping")409 if restores[0].get("id") != "cargo-cache":410 raise PolicyError(411 f"jobs.{job_name} must give its Cargo cache restore the cargo-cache id "412 "that the save condition reads"413 )414 if restore_with.get("key") != save_with.get("key"):415 raise PolicyError(416 f"jobs.{job_name} saves its Cargo cache under a different key than it "417 "restores"418 )419 if restore_with.get("path") != save_with.get("path"):420 raise PolicyError(421 f"jobs.{job_name} saves a different set of paths than it restores"422 )423424425def _gate_invocation(job_name: str, job: dict) -> dict:426 matches = [step for step in _steps(job, job_name) if GATE_SCRIPT in _run_body(step)]427 if len(matches) != 1:428 raise PolicyError(429 f"jobs.{job_name} must invoke the repository gate exactly once"430 )431 return matches[0]432433434def _validate_triggers(workflow: dict) -> None:435 if "defaults" in workflow:436 raise PolicyError(437 "workflow-level defaults are forbidden because they can suppress gate failures"438 )439 if set(workflow) != {"name", "on", "permissions", "concurrency", "jobs"}:440 raise PolicyError(441 "workflow top-level fields changed from the CI policy contract"442 )443444 triggers = _require_mapping(workflow["on"], "workflow on field")445 if "pull_request" not in triggers:446 raise PolicyError(447 "ordinary CI must contain exactly one empty pull_request trigger"448 )449 if triggers["pull_request"] is not None:450 raise PolicyError(451 "ordinary CI pull_request trigger must be exactly empty and unfiltered"452 )453 push = _require_mapping(triggers.get("push"), "workflow push trigger")454 if push.get("branches") != ["main"]:455 raise PolicyError("workflow push trigger must remain limited to main")456457458def _validate_scope_job(jobs: dict) -> None:459 job = _require_mapping(jobs.get(SCOPE_JOB), f"jobs.{SCOPE_JOB}")460 if "if" in job:461 raise PolicyError(f"jobs.{SCOPE_JOB} must not be conditionally disabled")462 outputs = _require_mapping(job.get("outputs"), f"jobs.{SCOPE_JOB}.outputs")463 if set(outputs) != {"code"}:464 raise PolicyError(f"jobs.{SCOPE_JOB} must publish exactly the code output")465466 body = "\n".join(_run_body(step) for step in _steps(job, SCOPE_JOB))467 for branch in REQUIRED_SCOPE_BRANCHES:468 if branch not in body:469 raise PolicyError(470 f"jobs.{SCOPE_JOB} is missing its fail-closed branch: {branch}"471 )472 if body.count("code=true") < 3:473 raise PolicyError(474 f"jobs.{SCOPE_JOB} must classify every unprovable diff as code"475 )476477 lines = [line.strip() for line in body.splitlines() if line.strip()]478 try:479 start = lines.index(REQUIRED_SCOPE_CASE[0])480 except ValueError:481 raise PolicyError(482 f"jobs.{SCOPE_JOB} must classify each changed path with a case statement"483 ) from None484 try:485 end = lines.index("esac", start)486 except ValueError:487 raise PolicyError(488 f"jobs.{SCOPE_JOB} path classification is not a closed case statement"489 ) from None490 if tuple(lines[start : end + 1]) != REQUIRED_SCOPE_CASE:491 raise PolicyError(492 f"jobs.{SCOPE_JOB} documentation path allowlist changed from the "493 "CI policy contract"494 )495496497def _validate_gate_legs(jobs: dict) -> None:498 for leg, stage in GATE_LEG_STAGES.items():499 job = _require_mapping(jobs.get(leg), f"jobs.{leg}")500 if job.get("runs-on") != REQUIRED_RUNNER:501 raise PolicyError(f"jobs.{leg} must run on {REQUIRED_RUNNER}")502503 step = _gate_invocation(leg, job)504 body = _run_body(step)505 if f"--stage {stage}" not in body:506 raise PolicyError(507 f"jobs.{leg} must run the repository gate stage {stage!r}"508 )509510 env = step.get("env")511 env = env if isinstance(env, dict) else {}512 if leg == "cross-build":513 if env.get("GOQ_REQUIRE_LINUX_CROSS_BUILD") != "1":514 raise PolicyError(515 "the cross-build leg does not require the Linux cross build"516 )517 if env.get("GOQ_VERIFY_IN_PROCESS_GSTREAMER") != "1":518 raise PolicyError(519 "the cross-build leg must cross-build the in-process GStreamer backend"520 )521 if leg == "gstreamer-gate":522 if env.get("GOQ_VERIFY_IN_PROCESS_GSTREAMER") != "1":523 raise PolicyError(524 "the GStreamer leg does not require the in-process GStreamer gate"525 )526527 installs = [528 candidate529 for candidate in _steps(job, leg)530 if DEPENDENCY_SCRIPT in _run_body(candidate)531 ]532 if len(installs) != 1:533 raise PolicyError(534 f"jobs.{leg} must install the Linux build dependencies exactly once"535 )536537538def _validate_summary_job(jobs: dict) -> None:539 job = _require_mapping(jobs.get(SUMMARY_JOB), f"jobs.{SUMMARY_JOB}")540 if job.get("name") != SUMMARY_JOB_NAME:541 raise PolicyError(f"jobs.{SUMMARY_JOB} must retain its required name")542 if job.get("runs-on") != REQUIRED_RUNNER:543 raise PolicyError(f"jobs.{SUMMARY_JOB} must run on {REQUIRED_RUNNER}")544 # The summary job is the one place an `if` is mandatory rather than545 # forbidden: it has to run when a leg has already failed so that it can546 # report that failure.547 if job.get("if") != "always()":548 raise PolicyError(549 f"jobs.{SUMMARY_JOB} must run with if: always() so a failed leg is reported"550 )551552 needs = job.get("needs")553 if not isinstance(needs, list):554 raise PolicyError(f"jobs.{SUMMARY_JOB} must declare a needs list")555 if len(needs) != len(set(needs)):556 raise PolicyError(f"jobs.{SUMMARY_JOB} needs list contains a duplicate")557 expected = set(jobs) - {SUMMARY_JOB}558 if set(needs) != expected:559 missing = sorted(expected - set(needs))560 unknown = sorted(set(needs) - expected)561 raise PolicyError(562 f"jobs.{SUMMARY_JOB} must depend on every other job; "563 f"missing={missing} unknown={unknown}"564 )565566 body = "\n".join(_run_body(step) for step in _steps(job, SUMMARY_JOB))567 if "exit 1" not in body:568 raise PolicyError(f"jobs.{SUMMARY_JOB} must fail when a leg did not pass")569 declared = re.search(r"skippable='([^']*)'", body)570 if declared is None:571 raise PolicyError(572 f"jobs.{SUMMARY_JOB} must declare which legs a docs-only diff may skip"573 )574 if set(declared.group(1).split()) != set(SKIPPABLE_JOBS):575 raise PolicyError(576 f"jobs.{SUMMARY_JOB} skippable legs changed from the CI policy contract"577 )578 for required in ("\"$SCOPE_CODE\" == 'false'", "leg_must_not_be_skipped"):579 if required not in body:580 raise PolicyError(581 f"jobs.{SUMMARY_JOB} must only tolerate a skip the docs-only scope made"582 )583584585def verify(workflow_text: str) -> None:586 workflow = Parser(workflow_text).parse()587 _validate_triggers(workflow)588 jobs = _require_mapping(workflow["jobs"], "top-level jobs field")589590 contract = ALWAYS_RUN_JOBS | SKIPPABLE_JOBS | {SUMMARY_JOB}591 if set(jobs) != contract:592 missing = sorted(contract - set(jobs))593 unknown = sorted(set(jobs) - contract)594 raise PolicyError(595 "workflow jobs changed from the CI policy contract: "596 f"missing={missing} unknown={unknown}"597 )598599 for name, job in jobs.items():600 _check_no_failure_suppression(name, _require_mapping(job, f"jobs.{name}"))601 _validate_caches(name, jobs[name])602603 for name in sorted(ALWAYS_RUN_JOBS):604 if "if" in jobs[name]:605 raise PolicyError(606 f"jobs.{name} must run for every change and must carry no if condition"607 )608 for name in sorted(SKIPPABLE_JOBS):609 if jobs[name].get("if") != SCOPE_CONDITION:610 raise PolicyError(611 f"jobs.{name} may only be skipped by the audited docs-only scope, "612 f"not by {jobs[name].get('if')!r}"613 )614615 _validate_scope_job(jobs)616 _validate_gate_legs(jobs)617 _validate_summary_job(jobs)618619620def verify_gate_script(gate_script: str) -> None:621 if not gate_script.startswith(GATE_SCRIPT_PREFIX):622 raise PolicyError(623 "complete repository gate executable prefix changed before its stage dispatch"624 )625626 # Checked before the structural assertions so that masking a failure is627 # reported as masking a failure rather than as a shape problem.628 for token in FORBIDDEN_RUN_TOKENS:629 if token in gate_script:630 raise PolicyError(631 f"complete repository gate contains the forbidden token {token!r}"632 )633634 if gate_script.count(LINUX_CROSS_BUILD_GATE) != 1:635 raise PolicyError(636 "complete repository gate must invoke the Linux cross-build gate exactly once"637 )638639 # Unconditional at its stage function's top level, so it cannot be buried in640 # a branch that never runs. scripts/tests/demo-gate-stages.sh is what proves641 # the stage actually reaches it; this only rejects the obvious shapes.642 if not re.search(rf"^ {re.escape(LINUX_CROSS_BUILD_GATE)}$", gate_script, re.M):643 raise PolicyError(644 "the Linux cross-build gate must be invoked unconditionally by its stage"645 )646647 declared = re.search(r"readonly ALL_STAGES=\(([^)]*)\)", gate_script)648 if declared is None:649 raise PolicyError("complete repository gate must declare its stage list")650 stages = declared.group(1).split()651 if set(stages) != set(GATE_LEG_STAGES.values()):652 raise PolicyError(653 "complete repository gate stages changed from the CI policy contract"654 )655656 for stage in stages:657 function = f"run_stage_{stage.replace('-', '_')}"658 if f"{function}()" not in gate_script:659 raise PolicyError(f"complete repository gate is missing {function}")660 if not re.search(rf"^\s*{re.escape(stage)}\) {function} ;;", gate_script, re.M):661 raise PolicyError(662 f"complete repository gate does not dispatch the {stage!r} stage"663 )664665 # With no arguments the gate must still run every stage, so the release and666 # hardware-UAT workflows keep the coverage they had as one serial job.667 if 'for current_stage in "${ALL_STAGES[@]}"' not in gate_script:668 raise PolicyError(669 "complete repository gate must run every stage when invoked with no stage"670 )671672673def main() -> int:674 parser = argparse.ArgumentParser()675 parser.add_argument("workflow", type=Path)676 parser.add_argument("gate_script", type=Path)677 args = parser.parse_args()678 try:679 verify(args.workflow.read_text(encoding="utf-8"))680 verify_gate_script(args.gate_script.read_text(encoding="utf-8"))681 except (OSError, PolicyError) as error:682 print(f"CI cross-build policy failed: {error}", file=sys.stderr)683 return 1684 print("ci_cross_build_policy=ok")685 return 0686687688if __name__ == "__main__":689 raise SystemExit(main())