from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError: sys.exit(
"scripts/ci_local.py needs PyYAML to read .github/workflows/ci.yml.\n"
"Install it with `pip install pyyaml` (or your distro's python3-yaml package)."
)
REPO_ROOT = Path(__file__).resolve().parent.parent
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "ci.yml"
COMPONENT_CHECKS = {
"rustfmt": ["cargo", "fmt", "--version"],
"clippy": ["cargo", "clippy", "--version"],
}
class StepSkipped(Exception):
def action_name(uses: str) -> str:
return uses.split("@", 1)[0]
def check_tool(argv: list[str], what: str) -> None:
if shutil.which(argv[0]) is None:
raise RuntimeError(f"{what} needs `{argv[0]}` on PATH, which CI installs for itself")
result = subprocess.run(argv, capture_output=True, text=True, check=False)
if result.returncode != 0:
raise RuntimeError(f"{what}: `{' '.join(argv)}` failed - is it installed?")
def tool_version(argv: list[str]) -> str:
result = subprocess.run(argv, capture_output=True, text=True, check=False)
return result.stdout.strip().splitlines()[0] if result.stdout.strip() else ""
def translate_uses(step: dict[str, Any]) -> list[str] | None:
name = action_name(step["uses"])
inputs = step.get("with") or {}
if name == "actions/checkout":
raise StepSkipped("already in the working tree")
if name == "Swatinem/rust-cache":
raise StepSkipped("local target/ is the cache")
if name == "dtolnay/rust-toolchain":
for component in str(inputs.get("components", "")).replace(",", " ").split():
check = COMPONENT_CHECKS.get(component, [component, "--version"])
check_tool(check, f"the {component} component")
raise StepSkipped("using the local toolchain")
if name == "actions/setup-node":
check_tool(["node", "--version"], "the mapping-site JS tests")
wanted = str(inputs.get("node-version", "")).split(".")[0]
local = tool_version(["node", "--version"]).lstrip("v").split(".")[0]
if wanted and local and wanted != local:
print(
f" note: CI uses node {wanted}, this machine has node {local}",
file=sys.stderr,
)
raise StepSkipped("using the local node")
if name == "astral-sh/setup-uv":
check_tool(["uv", "--version"], "the Python tests")
raise StepSkipped("using the local uv")
if name == "taiki-e/install-action":
tool = str(inputs.get("tool", "")) or str(step["uses"]).split("@", 1)[1]
check_tool(["cargo", tool, "--version"], f"the {tool} step")
raise StepSkipped(f"using the local cargo-{tool}")
if name == "astral-sh/ruff-action":
check_tool(["ruff", "--version"], "the Python lint")
wanted = str(inputs.get("version", ""))
local = tool_version(["ruff", "--version"]).split()[-1]
if wanted and local != wanted:
print(
f" note: CI pins ruff {wanted}, this machine has {local} - a lint difference"
" here may not reproduce in CI",
file=sys.stderr,
)
args = str(inputs.get("args", "")).split()
src = str(inputs.get("src", "")).split()
return ["ruff", *args, *src]
raise RuntimeError(
f"ci.yml uses `{step['uses']}`, which scripts/ci_local.py does not know how to run"
" locally.\nAdd it to translate_uses() - deliberately, so a new CI step cannot be"
" silently skipped here."
)
def expand(text: str, matrix: dict[str, Any], env: dict[str, str]) -> str:
out: list[str] = []
rest = text
while "${{" in rest:
before, _, after = rest.partition("${{")
expression, closed, rest = after.partition("}}")
if not closed:
raise RuntimeError(f"unterminated ${{{{ in: {text}")
expression = expression.strip()
if expression.startswith("matrix."):
value = matrix.get(expression.removeprefix("matrix."))
if value is None:
raise RuntimeError(f"no matrix value for `{expression}` in: {text}")
elif expression.startswith("env."):
value = env.get(expression.removeprefix("env."), "")
else:
raise RuntimeError(
f"scripts/ci_local.py cannot evaluate `${{{{ {expression} }}}}` in a run: step."
" Teach expand() about it rather than letting it run something CI does not."
)
out.append(before)
out.append(str(value))
out.append(rest)
return "".join(out)
def matrix_combinations(job: dict[str, Any]) -> list[dict[str, Any]]:
matrix = (job.get("strategy") or {}).get("matrix") or {}
axes = {key: value for key, value in matrix.items() if isinstance(value, list)}
if not axes:
return [{}]
combinations: list[dict[str, Any]] = [{}]
for key, values in axes.items():
combinations = [{**base, key: value} for base in combinations for value in values]
return combinations
def label(job_id: str, matrix: dict[str, Any]) -> str:
if not matrix:
return job_id
parts = ", ".join(f"{key}={value or 'default'}" for key, value in matrix.items())
return f"{job_id} ({parts})"
def run_job(job_id: str, job: dict[str, Any], matrix: dict[str, Any], env: dict[str, str]) -> bool:
heading = label(job_id, matrix)
print(f"\n\033[1m=== {heading} ===\033[0m", flush=True)
started = time.monotonic()
for step in job.get("steps") or []:
if "uses" in step:
try:
argv = translate_uses(step)
except StepSkipped as skipped:
print(f" - {step['uses']}: skipped ({skipped})", flush=True)
continue
except RuntimeError as error:
print(f" ! {error}", file=sys.stderr, flush=True)
return False
if argv is None:
continue
printable = " ".join(argv)
command: list[str] | str = argv
shell = False
else:
command = expand(step["run"], matrix, env)
printable = str(command).strip()
shell = True
print(f" $ {printable}", flush=True)
result = subprocess.run(
command,
shell=shell,
cwd=REPO_ROOT,
env={**os.environ, **env},
executable="/bin/bash" if shell else None,
check=False,
)
if result.returncode != 0:
print(
f"\033[31mFAILED\033[0m {heading}: exit {result.returncode}",
file=sys.stderr,
flush=True,
)
return False
print(f"\033[32mok\033[0m {heading} ({time.monotonic() - started:.1f}s)", flush=True)
return True
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--job",
action="append",
default=[],
metavar="ID",
help="run only this job (repeatable); see --list for the ids",
)
parser.add_argument("--list", action="store_true", help="list the jobs and exit")
parser.add_argument(
"--keep-going",
action="store_true",
help="run every job even after one fails, and report them all at the end",
)
args = parser.parse_args()
workflow = yaml.safe_load(WORKFLOW.read_text())
env = {key: str(value) for key, value in (workflow.get("env") or {}).items()}
jobs = workflow["jobs"]
selected = [job_id for job_id in jobs if not args.job or job_id in args.job]
unknown = [job_id for job_id in args.job if job_id not in jobs]
if unknown:
parser.error(f"no such job(s): {', '.join(unknown)} (see --list)")
if args.list:
for job_id in jobs:
for matrix in matrix_combinations(jobs[job_id]):
print(label(job_id, matrix))
return 0
failed: list[str] = []
for job_id in selected:
for matrix in matrix_combinations(jobs[job_id]):
if not run_job(job_id, jobs[job_id], matrix, env):
failed.append(label(job_id, matrix))
if not args.keep_going:
print(f"\n\033[31mCI failed:\033[0m {failed[0]}", file=sys.stderr)
return 1
if failed:
print("\n\033[31mCI failed:\033[0m", file=sys.stderr)
for name in failed:
print(f" - {name}", file=sys.stderr)
return 1
print("\n\033[32mCI passed\033[0m - every job in .github/workflows/ci.yml")
return 0
if __name__ == "__main__":
sys.exit(main())