Files
rustfs/scripts/ci_gate.py
T
2026-10-30 14:29:25 +08:00

696 lines
39 KiB
Python

#!/usr/bin/env python3
"""Select conservative PR scopes and verify the complete required job set."""
from __future__ import annotations
import fnmatch
import json
import posixpath
import os
from pathlib import Path, PurePosixPath
import re
import subprocess
import sys
import tomllib
import unittest
ROOT = Path(__file__).resolve().parent.parent
ALWAYS_JOBS = ("classify-changes", "typos", "quick-checks")
CODE_JOBS = (
"test-and-lint", "workspace-tests", "core-contract", "connect-feature-boundaries", "test-and-lint-rio-v2",
"test-and-lint-protocols", "build-rustfs-debug-binary", "uring-integration",
"e2e-tests", "s3-implemented-tests", "s3-lifecycle-behavior-tests",
)
# Full alternate-feature tests run on merge groups and on scheduled/manual
# runs. Pull requests and main pushes skip them: the nightly run reports a
# feature regression within a day, and repeating four builds per PR revision
# or per merge bought no earlier signal.
EXTENDED_FEATURE_JOBS = ("test-and-lint-rio-v2", "test-and-lint-protocols")
# These jobs still exercise the server and its black-box harness when only E2E
# Rust sources change. Production code, manifests and shared test configuration
# always select the full matrix. No production package depends on e2e_test.
E2E_JOBS = (
"test-and-lint", "workspace-tests", "core-contract", "build-rustfs-debug-binary", "e2e-tests",
"s3-implemented-tests", "s3-lifecycle-behavior-tests",
)
E2E_SELECTION_FILES = {
f".config/{profile}-selection.txt" for profile in (
"e2e-smoke", "e2e-full", "e2e-nightly", "e2e-repl-nightly",
"e2e-distributed", "e2e-protocols", "e2e-odm-interop",
)
}
SHARD_JOBS = ("workspace-shards", "workspace-shard-evidence")
OPTIONAL_JOBS = ("build-rustfs-debug-binary-rio-v2", "e2e-tests-rio-v2", "e2e-full", *SHARD_JOBS)
NON_VALIDATION_JOBS = {"required-checks", "cancel-closed-pr-runs", "alert-on-failure"}
# Report-only lanes stay outside the aggregate until their milestone makes them
# required. Each reruns a required job's suite with one environment change, so
# the named steps must stay identical to that job's and the change must stay.
REPORT_ONLY_JOBS = {
# rustfs/backlog#2734: required at T2.10.
"e2e-tests-gateway": (
"e2e-tests",
(
"Download E2E test binaries",
"Verify E2E archive identity",
"Verify e2e smoke test membership",
"Run e2e smoke suite",
"Verify e2e smoke execution completeness",
),
"RUSTFS_S3_STACK: gateway",
),
}
INTEGRATION_PUSH_REFS = ("refs/heads/main", "refs/heads/gateway/integration")
def documentation_path(path: str) -> bool:
parts = PurePosixPath(path).parts
if not parts or path.startswith("/") or any(part in (".", "..") for part in parts) or any(ord(c) < 32 for c in path):
return False
if parts[-1] == "AGENTS.md":
return True
if len(parts) == 1 and (path.endswith(".md") or path == "LICENSE" or path.startswith("LICENSE-")):
return True
if path.startswith(("docs/", ".agents/skills/")) and path.endswith(".md"):
return True
return path.startswith("docs/") and path.endswith((".png", ".jpg", ".svg"))
POLICY_PATH = ".config/ci-plan.json"
SHA = re.compile(r"[0-9a-f]{40}")
EVENTS = ("pull_request", "push", "merge_group", "schedule", "workflow_dispatch")
def git(root: Path, *args: str) -> str:
return subprocess.check_output(["git", *args], cwd=root, stderr=subprocess.PIPE).decode("utf-8")
def git_file(root: Path, revision: str, path: str) -> str:
if not SHA.fullmatch(revision):
raise ValueError("revision must be a full commit SHA")
return git(root, "show", f"{revision}:{path}")
def dependency_tables(document: dict):
for key, value in document.items():
if key in ("dependencies", "dev-dependencies", "build-dependencies"):
if not isinstance(value, dict):
raise ValueError("dependency table is not an object")
yield value
elif key == "target":
for target in value.values():
yield from dependency_tables(target)
def cargo_graph(root: Path, revision: str) -> tuple[dict[str, str], dict[str, set[str]]]:
"""Read Cargo inputs without resolving the network or executing candidate code.
All dependency kinds and target predicates are unioned. This intentionally
over-selects optional/target dependencies instead of guessing active cfgs.
"""
workspace = tomllib.loads(git_file(root, revision, "Cargo.toml"))
files = set(git(root, "ls-tree", "-r", "--name-only", "-z", revision).rstrip("\0").split("\0"))
manifests = {path for path in files if path.endswith("/Cargo.toml")}
members = workspace.get("workspace", {}).get("members", [])
excluded = workspace.get("workspace", {}).get("exclude", [])
selected = {path for path in manifests if any(fnmatch.fnmatchcase(path[:-11], member) for member in members)
and not any(fnmatch.fnmatchcase(path[:-11], pattern) for pattern in excluded)}
if "package" in workspace:
selected.add("Cargo.toml")
if not selected:
raise ValueError("workspace has no packages")
for member in members:
if not any(fnmatch.fnmatchcase(path[:-11], member) for path in manifests):
raise ValueError(f"workspace member has no manifest: {member}")
shared = workspace.get("workspace", {}).get("dependencies", {})
packages, graph, documents = {}, {}, {}
pending = set(selected)
while pending:
manifest = pending.pop()
if manifest in documents:
continue
document = tomllib.loads(git_file(root, revision, manifest))
directory = posixpath.dirname(manifest)
name = document["package"]["name"]
if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", name):
raise ValueError("invalid Cargo package name")
if name in packages and packages[name] != directory:
raise ValueError(f"duplicate workspace package {name}")
packages[name] = directory
documents[manifest] = (name, document)
graph[name] = set()
for table in dependency_tables(document):
for alias, value in table.items():
dependency = shared.get(alias) if isinstance(value, dict) and value.get("workspace") is True else value
if not isinstance(dependency, dict) or "path" not in dependency:
continue
owner = "" if isinstance(value, dict) and value.get("workspace") is True else directory
path = posixpath.normpath(posixpath.join(owner, dependency["path"], "Cargo.toml"))
if path.startswith("../") or path.startswith("/") or path not in files:
raise ValueError(f"unresolved workspace path dependency: {path}")
pending.add(path)
by_directory = {directory: name for name, directory in packages.items()}
for manifest, (name, document) in documents.items():
for table in dependency_tables(document):
for alias, value in table.items():
inherited = isinstance(value, dict) and value.get("workspace") is True
dependency = shared.get(alias) if inherited else value
if inherited and dependency is None:
raise ValueError(f"missing inherited dependency: {alias}")
if isinstance(dependency, dict) and "path" in dependency:
owner = "" if inherited else posixpath.dirname(manifest)
directory = posixpath.normpath(posixpath.join(owner, dependency["path"]))
graph[name].add(by_directory[directory])
else:
dependency_name = dependency.get("package", alias) if isinstance(dependency, dict) else alias
if dependency_name in packages:
graph[name].add(dependency_name)
return packages, graph
def closure(seeds: set[str], graph: dict[str, set[str]], reverse: bool = False) -> set[str]:
reached = set(seeds)
while True:
expanded = reached | ({name for name, deps in graph.items() if deps & reached} if reverse
else {dep for name in reached for dep in graph.get(name, set())})
if expanded == reached:
return reached
reached = expanded
def expected_results(mode: str, event: str, ref: str, connect: bool = True, action: str = "", rehearse_shards: bool = False) -> dict[str, str]:
if event not in EVENTS:
raise ValueError(f"unsupported CI event: {event!r}")
if mode not in ("docs", "e2e", "full") or (mode != "full" and event != "pull_request"):
raise ValueError(f"invalid CI selection: {mode!r} for {event!r}")
if type(rehearse_shards) is not bool or (rehearse_shards and event != "workflow_dispatch"):
raise ValueError("shard rehearsal requires an explicit manual event")
expected = {job: "success" for job in ALWAYS_JOBS}
expected.update({job: "success" if mode == "full" or (mode == "e2e" and job in E2E_JOBS) else "skipped" for job in CODE_JOBS})
expected["connect-feature-boundaries"] = "success" if mode == "full" and connect else "skipped"
if event in ("pull_request", "push"):
expected.update({job: "skipped" for job in EXTENDED_FEATURE_JOBS})
rio = mode == "full" and event in ("schedule", "workflow_dispatch")
expected.update({job: "success" if rio else "skipped" for job in OPTIONAL_JOBS[:2]})
full = mode == "full" and (event in ("merge_group", "workflow_dispatch") or (event == "push" and ref in INTEGRATION_PUSH_REFS))
expected["e2e-full"] = "success" if full else "skipped"
expected.update({job: "success" if rehearse_shards else "skipped" for job in SHARD_JOBS})
if event == "pull_request" and action == "closed":
return {job: "skipped" for job in expected}
return expected
def read_policy(root: Path, revision: str) -> dict:
policy = json.loads(git_file(root, revision, POLICY_PATH))
if policy.get("schema_version") != 1 or policy.get("rollout") not in ("shadow", "enforced"):
raise ValueError("unsupported CI policy")
for field in ("core_packages", "connect_packages", "full_paths"):
if not isinstance(policy.get(field), list) or not all(isinstance(value, str) and value for value in policy[field]):
raise ValueError(f"invalid CI policy {field}")
return policy
def configurations(jobs: dict[str, str]) -> list[dict]:
values = [
("workspace-lint", "test-and-lint", "dev", []),
("workspace-tests", "test-and-lint", "test", []),
("e2e-harness", "build-rustfs-debug-binary", "test", []),
("uring-tests", "uring-integration", "test", []),
("server-e2e-hooks", "build-rustfs-debug-binary", "dev", ["e2e-test-hooks"]),
("connect-debug", "connect-feature-boundaries", "test", ["connect-e2e-short-credentials"]),
("connect-offline-cli", "connect-feature-boundaries", "dev", ["offline-enrollment-e2e-root"]),
("connect-offline-tests", "connect-feature-boundaries", "test", ["offline-enrollment-e2e-root"]),
("connect-release", "connect-feature-boundaries", "release", []),
("connect-release-rejection", "connect-feature-boundaries", "release", ["connect-e2e-short-credentials"]),
("rio-v2-lint", "test-and-lint-rio-v2", "dev", ["rio-v2"]),
("rio-v2-tests", "test-and-lint-rio-v2", "test", ["rio-v2"]),
("protocol-swift-lint", "test-and-lint-protocols", "dev", ["swift"]),
("protocol-swift-tests", "test-and-lint-protocols", "test", ["swift"]),
("protocol-sftp-lint", "test-and-lint-protocols", "dev", ["sftp"]),
("protocol-sftp-tests", "test-and-lint-protocols", "test", ["sftp"]),
("protocol-http3-lint", "test-and-lint-protocols", "dev", ["http3", "swift"]),
("protocol-http3-tests", "test-and-lint-protocols", "test", ["http3", "swift"]),
("server-rio-v2", "build-rustfs-debug-binary-rio-v2", "dev", ["e2e-test-hooks", "rio-v2"]),
]
return [{"id": identity, "job": job, "target": "x86_64-unknown-linux-gnu", "profile": profile,
"default_features": True, "features": features} for identity, job, profile, features in values if jobs[job] == "success"]
def select_plan(event: str, base: str, head: str, ref: str, root: Path, action: str = "", rehearse_shards: bool = False) -> dict:
if event not in EVENTS or not SHA.fullmatch(head):
raise ValueError("unsupported event or missing candidate SHA")
tree = git(root, "rev-parse", f"{head}^{{tree}}").strip()
if not SHA.fullmatch(tree):
raise ValueError("candidate tree cannot be resolved")
head_packages, head_graph = cargo_graph(root, head)
all_packages = sorted(set(head_packages) - {"e2e_test"})
if not all_packages:
raise ValueError("candidate has no workspace validation packages")
policy_revision = base if event == "pull_request" and SHA.fullmatch(base) else head
reasons, changed, mode = [], [], "full"
policy = None
full = event != "pull_request"
try:
policy = read_policy(root, policy_revision)
base_packages, base_graph = cargo_graph(root, base) if SHA.fullmatch(base) else (head_packages, head_graph)
if event == "pull_request":
if not SHA.fullmatch(base):
raise ValueError("missing base SHA")
changed = git(root, "diff", "--no-ext-diff", "--no-textconv", "--no-renames", "--name-only", "-z", base, head, "--").rstrip("\0").split("\0")
if not changed or changed == [""]:
raise ValueError("empty change range")
else:
reasons.append(f"{event} validates the complete candidate")
except (OSError, subprocess.CalledProcessError, UnicodeError, KeyError, TypeError, ValueError) as error:
full = True
base_packages, base_graph = head_packages, head_graph
reasons.append(f"conservative fallback: {type(error).__name__}")
graph = {name: head_graph.get(name, set()) | base_graph.get(name, set()) for name in set(head_graph) | set(base_graph)}
affected = set()
if not full and policy is not None:
for path in changed:
if documentation_path(path):
continue
if path in E2E_SELECTION_FILES:
affected.add("e2e_test")
continue
# Cargo's graph cannot describe include_bytes!, fixture readers or
# build-script inputs shared across packages. Narrow only Rust
# sources; all other package-owned inputs retain the full gate.
if (not path.endswith(".rs") or PurePosixPath(path).name == "build.rs"
or "fixtures" in PurePosixPath(path).parts
or any(fnmatch.fnmatchcase(path, pattern) for pattern in policy["full_paths"])):
full = True
reasons.append(f"shared build or policy input: {path}")
continue
owners = {name for packages in (base_packages, head_packages) for name, directory in packages.items()
if directory and path.startswith(directory + "/")}
if not owners or path.startswith("/") or any(part in (".", "..") for part in path.split("/")) or any(ord(c) < 32 for c in path):
full = True
reasons.append(f"unmapped input: {path}")
affected.update(owners)
if not affected and not full:
mode = "docs"
reasons.append("all changes are documentation")
elif affected == {"e2e_test"} and not full and not (closure({"e2e_test"}, graph, reverse=True) - {"e2e_test"}) and all(
documentation_path(path) or path in E2E_SELECTION_FILES or
(path.startswith("crates/e2e_test/src/") and path.endswith(".rs")) for path in changed):
mode = "e2e"
reasons.append("isolated E2E harness changes")
core = set(policy["core_packages"]) if policy else set()
try:
# Union both inventories: a PR may add tests, but cannot remove baseline responsibility.
for revision in {policy_revision, head}:
inventory = json.loads(git_file(root, revision, ".config/ecstore-required-tests.json"))
if not inventory["tests"]:
raise ValueError("empty core test inventory")
core.update(test["suite"].split("::", 1)[0] for test in inventory["tests"])
if not core <= set(head_packages):
raise ValueError("core package missing from candidate")
except (subprocess.CalledProcessError, KeyError, TypeError, ValueError):
full = True
mode = "full"
reasons.append("core inventory cannot be resolved; require the full workspace")
selected = (closure(affected, graph, reverse=True) | core) & set(head_packages) - {"e2e_test"}
if full:
selected = set(all_packages)
if mode == "docs":
selected = set()
connect_inputs = closure(set(policy["connect_packages"]), graph) if policy else set(head_packages)
connect = mode == "full" and (full or "rustfs" in affected or bool(affected & connect_inputs))
reasons.append("selected changed packages, reverse dependencies and fixed core contracts")
reasons.append("Connect boundary or a shared input changed" if connect else "Connect has no changed credential/configuration dependency")
proposed = {"packages": sorted(selected), "connect": connect, "full_validation": full, "reasons": reasons,
"jobs": expected_results(mode, event, ref, connect, action, rehearse_shards)}
shadow = policy is None or policy["rollout"] == "shadow"
effective_packages = all_packages if shadow and mode != "docs" else sorted(selected)
effective_connect = mode == "full" if shadow else connect
effective_full = mode == "full" if shadow else full
jobs = expected_results(mode, event, ref, effective_connect, action, rehearse_shards)
plan = {"schema_version": 1, "event": event, "action": action, "ref": ref, "base": base if SHA.fullmatch(base) else "",
"head": head, "tree": tree, "policy": {"revision": policy_revision, "rollout": "shadow" if shadow else "enforced"},
"mode": mode, "packages": effective_packages, "full_validation": effective_full, "connect": effective_connect,
"rehearse_shards": rehearse_shards,
"configurations": configurations(jobs), "jobs": jobs, "proposed": proposed,
"reasons": ["shadow rollout preserves current validation responsibility"] + reasons if shadow else reasons,
"not_applicable": {job: f"not required for {mode}/{event}" for job, result in jobs.items() if result == "skipped"}}
return plan
def validate_plan(plan: object, event: str, ref: str, head: str = "", tree: str = "", base: str | None = None, action: str | None = None) -> dict[str, str]:
if not isinstance(plan, dict) or type(plan.get("schema_version")) is not int or plan["schema_version"] != 1:
raise ValueError("invalid plan schema")
if plan.get("event") != event or plan.get("ref") != ref:
raise ValueError("plan event/ref does not match this run")
if not isinstance(plan.get("base"), str) or plan["base"] and not SHA.fullmatch(plan["base"]):
raise ValueError("invalid plan base identity")
if not isinstance(plan.get("action"), str):
raise ValueError("invalid plan event action")
if (base is not None and plan["base"] != base) or (action is not None and plan["action"] != action):
raise ValueError("plan base/action does not match this run")
for field in ("head", "tree"):
if not isinstance(plan.get(field), str) or not SHA.fullmatch(plan[field]):
raise ValueError(f"invalid plan {field}")
if (head and plan["head"] != head) or (tree and plan["tree"] != tree):
raise ValueError("plan candidate identity does not match this run")
if any(type(plan.get(name)) is not bool for name in ("connect", "full_validation", "rehearse_shards")):
raise ValueError("invalid plan selection flags")
policy = plan.get("policy")
if not isinstance(policy, dict) or policy.get("rollout") not in ("shadow", "enforced") or not isinstance(policy.get("revision"), str) or not SHA.fullmatch(policy["revision"]):
raise ValueError("invalid plan policy identity")
if policy["rollout"] == "shadow" and plan.get("mode") == "full" and (not plan["connect"] or not plan["full_validation"]):
raise ValueError("shadow plan cannot reduce full validation responsibility")
packages = plan.get("packages")
if not isinstance(packages, list) or not all(isinstance(name, str) and re.fullmatch(r"[A-Za-z0-9_-]+", name) for name in packages) or len(packages) != len(set(packages)):
raise ValueError("invalid plan packages")
if plan.get("mode") != "docs" and not packages:
raise ValueError("code validation plan must not be empty")
expected = expected_results(plan.get("mode"), event, ref, plan["connect"], plan.get("action", ""), plan["rehearse_shards"])
if plan.get("jobs") != expected:
raise ValueError("plan job contract differs from selected responsibilities")
if plan.get("configurations") != configurations(expected):
raise ValueError("plan configuration matrix is incomplete")
if not isinstance(plan.get("reasons"), list) or not plan["reasons"] or not all(isinstance(reason, str) and reason for reason in plan["reasons"]):
raise ValueError("plan selection has no explanation")
not_applicable = plan.get("not_applicable")
if not isinstance(not_applicable, dict) or not all(isinstance(reason, str) and reason for reason in not_applicable.values()) or set(not_applicable) != {job for job, value in expected.items() if value == "skipped"}:
raise ValueError("plan skips lack explicit not-applicable reasons")
return expected
def verify_results(needs: object, event: str, ref: str, head: str = "", tree: str = "", base: str | None = None, action: str | None = None) -> list[str]:
if not isinstance(needs, dict):
return ["needs must be a job-result object"]
selection = needs.get("classify-changes", {})
outputs = selection.get("outputs", {}) if isinstance(selection, dict) else {}
try:
raw = outputs.get("plan") if isinstance(outputs, dict) else None
if raw:
plan = json.loads(raw)
expected = validate_plan(plan, event, ref, head, tree, base, action)
if outputs.get("mode") != plan["mode"]:
raise ValueError("plan mode differs from scheduling output")
for name in ("connect", "full_validation", "rehearse_shards"):
if name in outputs and outputs[name] != str(plan[name]).lower():
raise ValueError(f"plan {name} differs from scheduling output")
if outputs.get("packages") and json.loads(outputs["packages"]) != plan["packages"]:
raise ValueError("plan packages differ from scheduling output")
else:
# During adoption the trusted base script only emits `mode`. No
# legacy output may authorize an omitted job in the new workflow.
if not isinstance(outputs, dict) or outputs.get("mode") != "full":
raise ValueError("missing plan requires conservative full mode")
expected = expected_results("full", event, ref)
except (TypeError, ValueError) as error:
return [str(error)]
errors = []
if set(needs) != set(expected):
errors.append(f"job set differs: missing={sorted(set(expected) - set(needs))}, unexpected={sorted(set(needs) - set(expected))}")
for job, required in expected.items():
result = needs.get(job, {})
actual = result.get("result") if isinstance(result, dict) else None
if actual != required:
errors.append(f"{job}: expected {required}, got {actual!r}")
return errors
def workflow_document(text: str) -> dict:
try:
import yaml
except ImportError as error:
raise ValueError("workflow checking requires PyYAML") from error
class UniqueLoader(yaml.BaseLoader):
pass
def mapping(loader, node):
result = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node)
if key in result:
raise ValueError(f"duplicate YAML key: {key}")
result[key] = loader.construct_object(value_node)
return result
UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, mapping)
try:
value = yaml.load(text, Loader=UniqueLoader)
except yaml.YAMLError as error:
raise ValueError(f"invalid workflow YAML: {error}") from error
if not isinstance(value, dict):
raise ValueError("workflow must be a mapping")
return value
def condition(expression: str | bool, context: dict) -> bool:
"""Evaluate the finite condition grammar used by CI, never Python eval."""
if isinstance(expression, bool):
return expression
expression = expression.strip()
if expression.startswith("${{") and expression.endswith("}}"):
expression = expression[3:-2].strip()
token = re.compile(r"\s*(?:('(?:[^']|'')*')|([A-Za-z_][A-Za-z_0-9.-]*)|(&&|\|\||==|!=|[!(),]))")
values, cursor = [], 0
while cursor < len(expression):
match = token.match(expression, cursor)
if not match:
raise ValueError(f"unsupported condition near {expression[cursor:]!r}")
values.append(next(value for value in match.groups() if value is not None))
cursor = match.end()
position = 0
def atom():
nonlocal position
if position >= len(values):
raise ValueError("incomplete condition")
value = values[position]
position += 1
if value == "!":
return not atom()
if value == "(":
result = parse(0)
if position >= len(values) or values[position] != ")":
raise ValueError("unclosed condition")
position += 1
return result
if value.startswith("'"):
return value[1:-1].replace("''", "'")
if position < len(values) and values[position] == "(":
position += 1
arguments = []
while position < len(values) and values[position] != ")":
arguments.append(parse(0))
if position < len(values) and values[position] == ",":
position += 1
else:
break
if position >= len(values) or values[position] != ")":
raise ValueError("unclosed function")
position += 1
if value == "contains" and len(arguments) == 2:
return arguments[1] in arguments[0]
if value == "fromJSON" and len(arguments) == 1:
return json.loads(arguments[0])
if value in ("always", "success", "failure", "cancelled") and not arguments:
return True if value == "always" else context.get(value, False)
raise ValueError(f"unsupported condition function: {value}")
if value in ("true", "false"):
return value == "true"
if value not in context:
raise ValueError(f"unknown condition input: {value}")
return context[value]
precedence = {"||": 1, "&&": 2, "==": 3, "!=": 3}
def parse(minimum):
nonlocal position
left = atom()
while position < len(values) and precedence.get(values[position], 0) > minimum:
operator = values[position]
position += 1
right = parse(precedence[operator])
if operator == "||":
left = left or right
elif operator == "&&":
left = left and right
elif operator == "==":
left = left == right
else:
left = left != right
return left
result = parse(0)
if position != len(values):
raise ValueError("unexpected condition tokens")
return bool(result)
def check_workflow(root: Path) -> list[str]:
try:
document = workflow_document((root / ".github/workflows/ci.yml").read_text())
jobs = document["jobs"]
if not isinstance(jobs, dict):
raise ValueError("jobs must be a mapping")
except (OSError, KeyError, TypeError, ValueError) as error:
return [str(error)]
errors = []
required = set(ALWAYS_JOBS + CODE_JOBS + OPTIONAL_JOBS)
if set(jobs) - NON_VALIDATION_JOBS - set(REPORT_ONLY_JOBS) != required:
errors.append("CI verification jobs and the required gate contract differ")
for job, (mirror, steps, env) in REPORT_ONLY_JOBS.items():
block, mirrored = jobs.get(job), jobs.get(mirror)
if not isinstance(block, dict) or not isinstance(mirrored, dict):
errors.append(f"report-only {job} and its required {mirror} must both exist")
continue
key, _, value = env.partition(": ")
if (block.get("env") or {}).get(key) != value:
errors.append(f"{job} must set {env}")
def named(job_block, step):
matches = [item for item in job_block.get("steps") or [] if isinstance(item, dict) and item.get("name") == step]
return matches[0] if len(matches) == 1 else None
for step in steps:
ours, theirs = named(block, step), named(mirrored, step)
if ours is None or theirs is None or ours != theirs:
errors.append(f"{job} step {step!r} must match {mirror}")
for job in required | {"required-checks"}:
block = jobs.get(job, {})
for item in [block, *block.get("steps", [])]:
if item.get("continue-on-error", "false") != "false":
errors.append(f"{job} cannot convert a validation failure into success")
gate = jobs.get("required-checks", {})
dependencies = gate.get("needs", [])
if not isinstance(dependencies, list) or set(dependencies) != required or len(dependencies) != len(required):
errors.append("required-checks must directly depend on every verification job exactly once")
if gate.get("name") != "Test and Lint" or sum(block.get("name") == "Test and Lint" for block in jobs.values()) != 1:
errors.append("Test and Lint must uniquely name the aggregate gate")
for job, block in jobs.items():
if not isinstance(block.get("name"), str):
errors.append(f"{job} needs a stable display name")
name = block.get("name", "")
if "${{" in name and any(value.strip() not in ("matrix.features.name", "matrix.shard") for value in re.findall(r"\$\{\{(.*?)\}\}", name)):
errors.append(f"{job} has an unverifiable dynamic display name")
verifiers = [step for step in gate.get("steps", []) if "scripts/ci_gate.py verify" in step.get("run", "")]
if len(verifiers) != 1 or (verifiers[0].get("run", "").strip() != "python3 scripts/ci_gate.py verify"
or verifiers[0].get("env", {}).get("CI_NEEDS") != "${{ toJSON(needs) }}"
or verifiers[0].get("shell") != "bash" or "if" in verifiers[0]):
errors.append("required-checks must unconditionally verify actual needs and preserve its exit status")
lint_commands = ("cargo clippy --all-targets -- -D warnings", "python3 scripts/ci_test_artifact.py cargo lint --configuration workspace")
clippy = [step for step in jobs.get("test-and-lint", {}).get("steps", [])
if any(command in step.get("run", "") for command in lint_commands)]
if not clippy or any("if" in step for step in clippy):
errors.append("workspace Clippy must run on every code event, pull requests included")
matrix = jobs.get("test-and-lint-protocols", {}).get("strategy", {}).get("matrix", {})
features = matrix.get("features", [])
if not isinstance(features, list) or {item.get("name"): item.get("flags") for item in features} != {
"swift": "--features swift", "sftp": "--features sftp", "http3": "--features http3,swift"} or len(features) != 3:
errors.append("protocol matrix must retain every required feature configuration")
triggers = document.get("on", {})
if isinstance(triggers, dict) and isinstance(triggers.get("pull_request"), dict) and any(key in triggers["pull_request"] for key in ("paths", "paths-ignore")):
errors.append("all pull requests must enter the single CI workflow")
if (root / ".github/workflows/ci-docs-only.yml").exists():
errors.append("the duplicate required-status companion must be removed")
# Truth tables protect behavior while allowing equivalent expression and YAML formatting.
for event, rehearse_shards in [(event, rehearsal) for event in EVENTS
for rehearsal in ((False, True) if event == "workflow_dispatch" else (False,))]:
for mode in (("docs", "e2e", "full") if event == "pull_request" else ("full",)):
for ref in ("refs/heads/main", "refs/heads/release"):
for connect in (True, False) if event == "pull_request" and mode == "full" else (mode == "full",):
for action in ("opened", "closed") if event == "pull_request" else ("",):
expected = expected_results(mode, event, ref, connect, action, rehearse_shards)
context = {"github.event_name": event, "github.ref": ref, "github.event.action": action,
"needs.classify-changes.outputs.mode": mode, "needs.classify-changes.outputs.connect": str(connect).lower(),
"needs.classify-changes.outputs.rehearse_shards": str(rehearse_shards).lower(),
"success": True}
evaluated, evaluating = {}, set()
def scheduled(job):
if job in evaluated:
return evaluated[job]
if job in evaluating or job not in jobs:
raise ValueError(f"cyclic or unknown job dependency: {job}")
evaluating.add(job)
block = jobs[job]
dependencies = block.get("needs", [])
if isinstance(dependencies, str):
dependencies = [dependencies]
dependency_results = [scheduled(dependency) for dependency in dependencies]
success = all(dependency_results)
expression = block.get("if", "success()")
unquoted = re.sub(r"'(?:[^']|'')*'", "", expression)
status_function = re.search(r"\b(always|success|failure|cancelled)\s*\(", unquoted)
actual = condition(expression, dict(context, success=success))
evaluated[job] = actual and (bool(status_function) or success)
evaluating.remove(job)
return evaluated[job]
for job in required | {"required-checks"}:
should_run = expected.get(job, "success") == "success" if job != "required-checks" else action != "closed"
try:
actual = scheduled(job)
except (KeyError, TypeError, ValueError) as error:
errors.append(f"{job}: {error}")
continue
if actual != should_run:
errors.append(f"{job} condition differs from plan for {event}/{mode}/{connect}/{action}/{ref}")
for status in ("failure", "cancelled"):
context.update(success=False, failure=status == "failure", cancelled=status == "cancelled")
try:
if condition(gate.get("if", "true"), context) != (action != "closed") or not re.search(r"\balways\s*\(", re.sub(r"'(?:[^']|'')*'", "", gate.get("if", ""))):
errors.append("required-checks must run after failed or skipped dependencies")
except (TypeError, ValueError) as error:
errors.append(str(error))
return sorted(set(errors))
def main() -> int:
if sys.argv[1:] == ["--self-test"]:
suite = unittest.defaultTestLoader.discover(str(ROOT / "scripts"), pattern="test_ci_plan.py")
return not unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful()
if sys.argv[1:] == ["select"]:
try:
plan = select_plan(os.environ.get("GITHUB_EVENT_NAME", ""), os.environ.get("CI_BASE_SHA", ""),
os.environ.get("GITHUB_SHA", ""), os.environ.get("GITHUB_REF", ""),
Path.cwd(), os.environ.get("CI_EVENT_ACTION", ""), os.environ.get("CI_REHEARSE_SHARDS", "false") == "true")
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
for name, value in (("mode", plan["mode"]), ("plan", json.dumps(plan, separators=(",", ":"))),
("connect", str(plan["connect"]).lower()), ("packages", json.dumps(plan["packages"], separators=(",", ":"))),
("full_validation", str(plan["full_validation"]).lower()),
("rehearse_shards", str(plan["rehearse_shards"]).lower())):
output.write(f"{name}={value}\n")
print(json.dumps(plan, indent=2))
if os.environ.get("GITHUB_STEP_SUMMARY"):
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary:
summary.write("### CI validation plan\n\n```json\n" + json.dumps(plan, indent=2) + "\n```\n")
return 0
except (OSError, subprocess.CalledProcessError, UnicodeError, KeyError, TypeError, ValueError) as error:
print(f"ERROR: cannot safely plan CI: {error}", file=sys.stderr)
return 1
if sys.argv[1:] == ["verify"]:
try:
head = os.environ.get("GITHUB_SHA", "")
tree = git(Path.cwd(), "rev-parse", f"{head}^{{tree}}").strip() if SHA.fullmatch(head) else ""
event = os.environ.get("GITHUB_EVENT_NAME", "")
base, action = None, None
if os.environ.get("GITHUB_EVENT_PATH"):
payload = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
base = (payload["pull_request"]["base"]["sha"] if event == "pull_request" else
payload["merge_group"]["base_sha"] if event == "merge_group" else payload.get("before", ""))
action = payload.get("action", "")
errors = verify_results(json.loads(os.environ["CI_NEEDS"]), event, os.environ.get("GITHUB_REF", ""), head, tree, base, action)
except (OSError, KeyError, TypeError, ValueError, subprocess.CalledProcessError) as error:
errors = [str(error)]
elif sys.argv[1:] == ["--check-workflow"]:
errors = check_workflow(ROOT)
else:
print("usage: ci_gate.py {select|verify|--check-workflow|--self-test}", file=sys.stderr)
return 2
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
if not errors:
print("CI contract passed")
return bool(errors)
if __name__ == "__main__":
raise SystemExit(main())