cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

release/v0.1.0-pins
goq / scripts / verify_ci_cross_build_policy.py
verify_ci_cross_build_policy.py 18.2K
1#!/usr/bin/env python32"""Validate the executable semantics of CI's required glibc cross-build path."""34from __future__ import annotations56import argparse7import re8import sys9from dataclasses import dataclass, field10from pathlib import Path111213CHECKOUT_ACTION = "actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8"14CACHE_ACTION = "actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809"15CACHE_PATH = "~/.cargo/bin/cargo-zigbuild"16CACHE_KEY = "cargo-zigbuild-${{ runner.os }}-${{ runner.arch }}-0.23.0"17LINUX_CROSS_BUILD_GATE = "./scripts/run-linux-cross-build-gate.sh"18MAPPING_KEY = r"""(?:[A-Za-z0-9_-]+|"[A-Za-z0-9_-]+"|'[A-Za-z0-9_-]+')"""19REQUIRED_STEP_NAMES = [20 "Check out repository",21 "Install Linux build dependencies",22 "Restore pinned cargo-zigbuild",23 "Install pinned cross-build tools",24 "Run complete demo gate",25]26GATE_SCRIPT_PREFIX = """\27#!/usr/bin/env bash28set -euo pipefail2930script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"31repo_dir="$(cd -- "$script_dir/.." && pwd -P)"32cd "$repo_dir"3334if [[ -f "$HOME/.cargo/env" ]]; then35 # shellcheck source=/dev/null36 source "$HOME/.cargo/env"37fi3839./scripts/run-linux-cross-build-gate.sh40"""4142DEPENDENCY_RUN = """\43sudo apt-get update44sudo apt-get install --yes --no-install-recommends \\45 build-essential \\46 file \\47 ffmpeg \\48 gstreamer1.0-plugins-base \\49 gstreamer1.0-plugins-bad \\50 gstreamer1.0-plugins-good \\51 gstreamer1.0-plugins-ugly \\52 gstreamer1.0-tools \\53 libayatana-appindicator3-dev \\54 libgstreamer-plugins-base1.0-dev \\55 libgstreamer1.0-dev \\56 librsvg2-dev \\57 libssl-dev \\58 libudev-dev \\59 libwebkit2gtk-4.1-dev \\60 libxdo-dev \\61 pkg-config \\62 python3-venv \\63 shellcheck"""6465INSTALL_RUN = """\66python3 -m venv "$RUNNER_TEMP/zig-venv"67printf '%s\\n' \\68 'ziglang==0.16.0 --hash=sha256:9fcda73f62b851dd72a54b710ad40a209896db14cfb13649e62191243556342b' \\69 | "$RUNNER_TEMP/zig-venv/bin/pip" install \\70 --disable-pip-version-check \\71 --only-binary=:all: \\72 --require-hashes \\73 --requirement /dev/stdin74install -d "$RUNNER_TEMP/ci-bin"75# Expand runner paths when the generated wrapper executes.76# shellcheck disable=SC201677printf '%s\\n' \\78 '#!/usr/bin/env bash' \\79 'exec "$RUNNER_TEMP/zig-venv/bin/python" -m ziglang "$@"' \\80 >"$RUNNER_TEMP/ci-bin/zig"81chmod 0755 "$RUNNER_TEMP/ci-bin/zig"82echo "$RUNNER_TEMP/ci-bin" >>"$GITHUB_PATH"83export PATH="$RUNNER_TEMP/ci-bin:$PATH"84if ! cargo-zigbuild --version 2>/dev/null \\85 | grep -Fxq 'cargo-zigbuild 0.23.0'; then86 cargo install cargo-zigbuild --locked --version 0.23.087fi88test "$(zig version)" = 0.16.089test "$(cargo-zigbuild --version)" = 'cargo-zigbuild 0.23.0'"""909192class PolicyError(ValueError):93 """The workflow does not meet the mandatory cross-build policy."""949596@dataclass97class Step:98 name: str99 scalars: dict[str, str] = field(default_factory=dict)100 mappings: dict[str, dict[str, str]] = field(default_factory=dict)101 blocks: dict[str, str] = field(default_factory=dict)102103 @property104 def field_names(self) -> set[str]:105 return {"name", *self.scalars, *self.mappings, *self.blocks}106107108def _indent(line: str) -> int:109 return len(line) - len(line.lstrip(" "))110111112def _is_content(line: str) -> bool:113 stripped = line.strip()114 return bool(stripped) and not stripped.startswith("#")115116117def _scalar(value: str) -> str:118 value = value.split(" #", 1)[0].strip()119 if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":120 return value[1:-1]121 return value122123124def _mapping_entry(line: str, indent: int) -> tuple[str, str] | None:125 if _indent(line) != indent or not _is_content(line):126 return None127 match = re.fullmatch(128 rf" {{{indent}}}({MAPPING_KEY}):(?:\s*(.*))?",129 line,130 )131 if not match:132 return None133 return _scalar(match.group(1)), (match.group(2) or "").strip()134135136def _mapping_key(line: str, indent: int) -> str | None:137 entry = _mapping_entry(line, indent)138 return entry[0] if entry else None139140141def _block_end(lines: list[str], start: int, indent: int) -> int:142 for index in range(start + 1, len(lines)):143 if _mapping_entry(lines[index], indent) is not None:144 return index145 if _is_content(lines[index]) and _indent(lines[index]) < indent:146 return index147 return len(lines)148149150def _validate_workflow_layout(workflow: str) -> None:151 lines = workflow.splitlines()152 for line in lines:153 if _is_content(line) and _indent(line) == 0:154 if _mapping_entry(line, 0) is None:155 raise PolicyError(f"cannot parse top-level workflow field: {line}")156 top_level = [157 (index, entry)158 for index, line in enumerate(lines)159 if (entry := _mapping_entry(line, 0)) is not None160 ]161 top_level_names = [key for _, (key, _) in top_level]162 if len(top_level_names) != len(set(top_level_names)):163 raise PolicyError("workflow contains a duplicate top-level field")164165 if any(key == "defaults" for _, (key, _) in top_level):166 raise PolicyError(167 "workflow-level defaults are forbidden because they can suppress gate failures"168 )169 allowed_top_level = {"name", "on", "permissions", "concurrency", "jobs"}170 if set(top_level_names) != allowed_top_level:171 raise PolicyError("workflow top-level fields changed from the CI policy contract")172173 on_entries = [(index, value) for index, (key, value) in top_level if key == "on"]174 if len(on_entries) != 1:175 raise PolicyError("workflow must contain exactly one top-level on mapping")176 on_start, on_value = on_entries[0]177 if _scalar(on_value):178 raise PolicyError("workflow on field must be a trigger mapping")179 on_end = _block_end(lines, on_start, 0)180 pull_request_indices = [181 index182 for index in range(on_start + 1, on_end)183 if _mapping_key(lines[index], 2) == "pull_request"184 ]185 if len(pull_request_indices) != 1:186 raise PolicyError(187 "ordinary CI must contain exactly one empty pull_request trigger"188 )189 pull_request_index = pull_request_indices[0]190 pull_request_entry = _mapping_entry(lines[pull_request_index], 2)191 if pull_request_entry is None or _scalar(pull_request_entry[1]):192 raise PolicyError("ordinary CI pull_request trigger must be exactly empty")193194 pull_request_end = on_end195 for index in range(pull_request_index + 1, on_end):196 if _is_content(lines[index]) and _indent(lines[index]) <= 2:197 pull_request_end = index198 break199 if any(200 _is_content(line)201 for line in lines[pull_request_index + 1 : pull_request_end]202 ):203 raise PolicyError(204 "ordinary CI pull_request trigger must be exactly empty and unfiltered"205 )206207208def _job_lines(workflow: str, job_name: str) -> list[str]:209 lines = workflow.splitlines()210 jobs_indices = [211 index212 for index, line in enumerate(lines)213 if _mapping_key(line, 0) == "jobs"214 ]215 if len(jobs_indices) != 1:216 raise PolicyError("workflow must contain exactly one top-level jobs mapping")217 jobs_entry = _mapping_entry(lines[jobs_indices[0]], 0)218 if jobs_entry is None or _scalar(jobs_entry[1]):219 raise PolicyError("top-level jobs field must be a mapping")220221 jobs_start = jobs_indices[0] + 1222 jobs_end = len(lines)223 for index in range(jobs_start, len(lines)):224 if _is_content(lines[index]) and _indent(lines[index]) == 0:225 jobs_end = index226 break227228 matches = [229 index230 for index in range(jobs_start, jobs_end)231 if _mapping_key(lines[index], 2) == job_name232 ]233 if len(matches) != 1:234 raise PolicyError(f"jobs.{job_name} must exist exactly once")235236 job_start = matches[0]237 job_entry = _mapping_entry(lines[job_start], 2)238 if job_entry is None or _scalar(job_entry[1]):239 raise PolicyError(f"jobs.{job_name} must be a mapping")240 job_end = jobs_end241 for index in range(job_start + 1, jobs_end):242 if _mapping_key(lines[index], 2) is not None:243 job_end = index244 break245 return lines[job_start:job_end]246247248def _parse_step(block: list[str]) -> Step:249 match = re.fullmatch(r" - name:\s*(.+?)\s*", block[0])250 if not match:251 raise PolicyError("every demo-gate step must begin with an explicit name")252 step = Step(name=_scalar(match.group(1)))253254 index = 1255 while index < len(block):256 line = block[index]257 if not _is_content(line):258 index += 1259 continue260 if _indent(line) != 8:261 raise PolicyError(f"unexpected YAML structure in step {step.name!r}: {line}")262 match = re.fullmatch(rf" ({MAPPING_KEY}):(?:\s*(.*))?", line)263 if not match:264 raise PolicyError(f"cannot parse field in step {step.name!r}: {line}")265 key = _scalar(match.group(1))266 value = (match.group(2) or "").strip()267 if key in step.field_names:268 raise PolicyError(f"duplicate field {key!r} in step {step.name!r}")269270 if value == "|":271 index += 1272 body: list[str] = []273 while index < len(block):274 child = block[index]275 if _is_content(child) and _indent(child) <= 8:276 break277 if child.strip():278 if _indent(child) < 10:279 raise PolicyError(280 f"invalid block indentation in step {step.name!r}"281 )282 body.append(child[10:])283 else:284 body.append("")285 index += 1286 step.blocks[key] = "\n".join(body).rstrip()287 continue288289 if not value:290 index += 1291 entries: dict[str, str] = {}292 while index < len(block):293 child = block[index]294 if not _is_content(child):295 index += 1296 continue297 if _indent(child) <= 8:298 break299 child_match = re.fullmatch(300 rf" ({MAPPING_KEY}):\s*(.+?)\s*", child301 )302 if not child_match:303 raise PolicyError(304 f"cannot parse {key!r} mapping in step {step.name!r}: {child}"305 )306 child_key = _scalar(child_match.group(1))307 if child_key in entries:308 raise PolicyError(309 f"duplicate {key}.{child_key} in step {step.name!r}"310 )311 entries[child_key] = _scalar(child_match.group(2))312 index += 1313 step.mappings[key] = entries314 continue315316 step.scalars[key] = _scalar(value)317 index += 1318319 return step320321322def _parse_steps(job: list[str]) -> list[Step]:323 job_entries: list[tuple[str, str]] = []324 for line in job:325 if not _is_content(line) or _indent(line) != 4:326 continue327 entry = _mapping_entry(line, 4)328 if entry is None:329 raise PolicyError(f"cannot parse jobs.demo-gate field: {line}")330 job_entries.append(entry)331 job_field_list = [key for key, _ in job_entries]332 if len(job_field_list) != len(set(job_field_list)):333 raise PolicyError("jobs.demo-gate contains a duplicate field")334 job_fields = set(job_field_list)335 if "if" in job_fields:336 raise PolicyError("jobs.demo-gate must not be conditionally disabled")337 if "continue-on-error" in job_fields:338 raise PolicyError("jobs.demo-gate must not suppress failures")339 if "defaults" in job_fields:340 raise PolicyError(341 "jobs.demo-gate must not override run defaults or shell failure behavior"342 )343 if job_fields != {"name", "runs-on", "timeout-minutes", "env", "steps"}:344 raise PolicyError("jobs.demo-gate fields changed from the CI policy contract")345 job_values = {key: value for key, value in job_entries}346 if _scalar(job_values["name"]) != "Complete demo gate":347 raise PolicyError("jobs.demo-gate must retain its required name")348 if _scalar(job_values["runs-on"]) != "ubuntu-24.04":349 raise PolicyError("jobs.demo-gate must run on ubuntu-24.04")350 if _scalar(job_values["timeout-minutes"]) != "45":351 raise PolicyError("jobs.demo-gate timeout must remain 45 minutes")352 if _scalar(job_values["env"]):353 raise PolicyError("jobs.demo-gate env field must be an exact mapping")354 if _scalar(job_values["steps"]):355 raise PolicyError("jobs.demo-gate steps field must be a list")356357 env_indices = [358 index359 for index, line in enumerate(job)360 if _mapping_key(line, 4) == "env"361 ]362 if len(env_indices) != 1:363 raise PolicyError("jobs.demo-gate must contain exactly one env mapping")364 env_start = env_indices[0]365 env_end = len(job)366 for index in range(env_start + 1, len(job)):367 if _is_content(job[index]) and _indent(job[index]) <= 4:368 env_end = index369 break370 env_entries: list[tuple[str, str]] = []371 for line in job[env_start + 1 : env_end]:372 if not _is_content(line):373 continue374 entry = _mapping_entry(line, 6)375 if entry is None:376 raise PolicyError(f"cannot parse jobs.demo-gate env field: {line}")377 env_entries.append(entry)378 env_names = [key for key, _ in env_entries]379 if len(env_names) != len(set(env_names)):380 raise PolicyError("jobs.demo-gate env contains a duplicate field")381 env = {key: _scalar(value) for key, value in env_entries}382 if env != {"CARGO_TERM_COLOR": "always", "RUST_BACKTRACE": "1"}:383 raise PolicyError("jobs.demo-gate env changed from the CI policy contract")384385 steps_indices = [386 index387 for index, line in enumerate(job)388 if line == " steps:"389 ]390 if len(steps_indices) != 1:391 raise PolicyError("jobs.demo-gate must contain exactly one steps list")392393 start = steps_indices[0] + 1394 starts = [395 index396 for index in range(start, len(job))397 if re.fullmatch(r" - name:\s*.+", job[index])398 ]399 if not starts:400 raise PolicyError("jobs.demo-gate contains no named steps")401402 steps: list[Step] = []403 for position, step_start in enumerate(starts):404 step_end = starts[position + 1] if position + 1 < len(starts) else len(job)405 steps.append(_parse_step(job[step_start:step_end]))406 return steps407408409def _named_step(steps: list[Step], name: str) -> tuple[int, Step]:410 matches = [411 (index, step)412 for index, step in enumerate(steps)413 if step.name == name414 ]415 if len(matches) != 1:416 raise PolicyError(f"demo-gate step {name!r} must exist exactly once")417 return matches[0]418419420def verify_gate_script(gate_script: str) -> None:421 if not gate_script.startswith(GATE_SCRIPT_PREFIX):422 raise PolicyError(423 "complete repository gate executable prefix changed before the required Linux cross build"424 )425426 if gate_script.count(LINUX_CROSS_BUILD_GATE) != 1:427 raise PolicyError(428 "complete repository gate must invoke the Linux cross-build gate exactly once"429 )430431432def verify(workflow: str) -> None:433 _validate_workflow_layout(workflow)434 steps = _parse_steps(_job_lines(workflow, "demo-gate"))435436 checkout_index, checkout = _named_step(steps, "Check out repository")437 dependency_index, dependency = _named_step(438 steps, "Install Linux build dependencies"439 )440 cache_index, cache = _named_step(steps, "Restore pinned cargo-zigbuild")441 install_index, install = _named_step(steps, "Install pinned cross-build tools")442 gate_index, gate = _named_step(steps, "Run complete demo gate")443444 if [step.name for step in steps] != REQUIRED_STEP_NAMES:445 raise PolicyError(446 "demo-gate step list or order changed from the CI policy contract"447 )448449 if checkout.field_names != {"name", "uses"}:450 raise PolicyError("checkout step has unexpected or missing fields")451 if checkout.scalars.get("uses") != CHECKOUT_ACTION:452 raise PolicyError("checkout action is not digest-pinned to the policy contract")453454 if dependency.field_names != {"name", "run"}:455 raise PolicyError("Linux dependency step has unexpected or missing fields")456 if dependency.blocks.get("run") != DEPENDENCY_RUN:457 raise PolicyError("Linux dependency step body changed")458459 if cache.field_names != {"name", "uses", "with"}:460 raise PolicyError("cargo-zigbuild cache step has unexpected or missing fields")461 if cache.scalars.get("uses") != CACHE_ACTION:462 raise PolicyError("cargo-zigbuild cache action is not digest-pinned")463 if cache.mappings.get("with") != {"path": CACHE_PATH, "key": CACHE_KEY}:464 raise PolicyError("cargo-zigbuild cache contract changed")465466 if install.field_names != {"name", "run"}:467 raise PolicyError("cross-build install step has unexpected or missing fields")468 if install.blocks.get("run") != INSTALL_RUN:469 raise PolicyError("cross-build install step body changed")470471 if gate.field_names != {"name", "env", "run"}:472 raise PolicyError("complete demo gate step has unexpected or missing fields")473 if gate.mappings.get("env") != {474 "GOQ_VERIFY_IN_PROCESS_GSTREAMER": "1",475 "GOQ_REQUIRE_LINUX_CROSS_BUILD": "1",476 }:477 raise PolicyError("complete demo gate does not require the Linux cross build")478 if gate.scalars.get("run") != "./scripts/verify-demo-build.sh":479 raise PolicyError("complete demo gate does not execute the repository gate")480481 if [482 checkout_index,483 dependency_index,484 cache_index,485 install_index,486 gate_index,487 ] != list(range(len(REQUIRED_STEP_NAMES))):488 raise PolicyError(489 "demo-gate step list or order changed from the CI policy contract"490 )491492493def main() -> int:494 parser = argparse.ArgumentParser()495 parser.add_argument("workflow", type=Path)496 parser.add_argument("gate_script", type=Path)497 args = parser.parse_args()498 try:499 verify(args.workflow.read_text(encoding="utf-8"))500 verify_gate_script(args.gate_script.read_text(encoding="utf-8"))501 except (OSError, PolicyError) as error:502 print(f"CI cross-build policy failed: {error}", file=sys.stderr)503 return 1504 print("ci_cross_build_policy=ok")505 return 0506507508if __name__ == "__main__":509 raise SystemExit(main())