Skip to content

Commit 88560a8

Browse files
author
CometAPI
committed
ci: close workflow validation bypasses
1 parent 45a4b98 commit 88560a8

5 files changed

Lines changed: 494 additions & 10 deletions

File tree

scripts/check_secrets.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ def _scan_content(root: Path) -> list[str]:
9494
def _scan_workflow_scope(root: Path) -> list[str]:
9595
findings: list[str] = []
9696
workflow_root = root / ".github" / "workflows"
97+
if not workflow_root.is_dir():
98+
return findings
9799
ci = workflow_root / "ci.yml"
98100
if ci.is_file() and re.search(
99101
r"\$\{\{\s*secrets\.", ci.read_text(encoding="utf-8"), flags=re.IGNORECASE
@@ -110,7 +112,11 @@ def _scan_workflow_scope(root: Path) -> list[str]:
110112
findings.append(
111113
".github/workflows/publish.yml: exactly one job must receive id-token: write"
112114
)
113-
for path in workflow_root.glob("*.yml"):
115+
for path in sorted(
116+
candidate
117+
for candidate in workflow_root.iterdir()
118+
if candidate.is_file() and candidate.suffix in {".yaml", ".yml"}
119+
):
114120
text = path.read_text(encoding="utf-8")
115121
if path.name != "publish.yml" and "id-token: write" in text:
116122
findings.append(f"{path.relative_to(root)}: id-token: write is publish-job-only")

scripts/check_version.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ def require_public_preview_docs() -> None:
160160
documents = _read_public_documents(violations)
161161
_check_project_identity(violations)
162162

163-
if Path(".github/CODEOWNERS").exists():
163+
codeowners = Path(".github/CODEOWNERS")
164+
if codeowners.exists() or codeowners.is_symlink():
164165
violations.append(
165166
".github/CODEOWNERS: must remain absent until a real multi-maintainer model exists"
166167
)

scripts/check_workflows.py

Lines changed: 225 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@ def _scalar(value: object, label: str) -> str:
4141
return value
4242

4343

44+
def _require_exact_keys(mapping: dict[str, object], expected: set[str], label: str) -> None:
45+
actual = set(mapping)
46+
if actual != expected:
47+
missing = ", ".join(sorted(expected - actual)) or "none"
48+
unexpected = ", ".join(sorted(actual - expected)) or "none"
49+
raise CheckError(
50+
f"{label} keys do not match the reviewed contract "
51+
f"(missing: {missing}; unexpected: {unexpected})"
52+
)
53+
54+
4455
def _load_workflow(text: str, source: str) -> dict[str, object]:
4556
try:
4657
loaded: object = yaml.load(text, Loader=yaml.BaseLoader)
@@ -214,6 +225,22 @@ def _require_step_environments(
214225
raise CheckError(f"{label} {name!r} step must not override the environment")
215226

216227

228+
def _require_step_working_directories(
229+
job: dict[str, object], expected: dict[str, str], label: str
230+
) -> None:
231+
matched: set[str] = set()
232+
for index, step in enumerate(_workflow_steps(job, label)):
233+
name = _scalar(step.get("name"), f"{label} step {index} name")
234+
if name in expected:
235+
if step.get("working-directory") != expected[name]:
236+
raise CheckError(f"{label} {name!r} step must use its reviewed working directory")
237+
matched.add(name)
238+
elif "working-directory" in step:
239+
raise CheckError(f"{label} {name!r} step must run from the checked-out repository root")
240+
if matched != set(expected):
241+
raise CheckError(f"{label} reviewed working-directory steps are missing")
242+
243+
217244
def _action_references(workflow: dict[str, object], source: str) -> Iterator[tuple[str, str]]:
218245
jobs = _mapping(workflow.get("jobs"), f"{source} jobs")
219246
for job_name, value in jobs.items():
@@ -272,18 +299,45 @@ def check_ci_workflow(text: str) -> None:
272299
schedule = _sequence(triggers["schedule"], "CI schedule trigger")
273300
if schedule != [{"cron": "23 4 * * 1"}]:
274301
raise CheckError("CI latest-OpenAI canary must run on the reviewed weekly schedule")
302+
concurrency = _mapping(workflow.get("concurrency"), "CI workflow concurrency")
303+
if concurrency != {
304+
"group": "ci-${{ github.workflow }}-${{ github.ref }}",
305+
"cancel-in-progress": "true",
306+
}:
307+
raise CheckError("CI must retain reviewed per-ref cancellation")
308+
_require_exact_keys(
309+
workflow,
310+
{"name", "on", "permissions", "concurrency", "env", "jobs"},
311+
"CI workflow",
312+
)
313+
if _secret_references(workflow):
314+
raise CheckError("credential-free CI must not reference repository secrets")
275315

316+
jobs = _mapping(workflow.get("jobs"), "CI workflow jobs")
317+
expected_jobs = {
318+
"quality",
319+
"locked-runtime",
320+
"minimum-openai",
321+
"latest-openai",
322+
"package",
323+
"standalone",
324+
}
325+
if set(jobs) != expected_jobs:
326+
raise CheckError("CI jobs must match the reviewed validation chain")
276327
quality = _workflow_job(workflow, "quality", "CI workflow")
277328
locked_runtime = _workflow_job(workflow, "locked-runtime", "CI workflow")
278329
minimum_openai = _workflow_job(workflow, "minimum-openai", "CI workflow")
279330
latest_openai = _workflow_job(workflow, "latest-openai", "CI workflow")
280331
package = _workflow_job(workflow, "package", "CI workflow")
281332
standalone = _workflow_job(workflow, "standalone", "CI workflow")
282-
jobs = _mapping(workflow.get("jobs"), "CI workflow jobs")
283-
for name, value in jobs.items():
284-
job = _mapping(value, f"CI {name!r} job")
285-
if "permissions" in job:
286-
raise CheckError("CI jobs must not override credential-free workflow permissions")
333+
expected_job_keys = {
334+
"quality": {"name", "runs-on", "timeout-minutes", "steps"},
335+
"locked-runtime": {"name", "runs-on", "timeout-minutes", "strategy", "steps"},
336+
"minimum-openai": {"name", "runs-on", "timeout-minutes", "steps"},
337+
"latest-openai": {"name", "if", "runs-on", "timeout-minutes", "steps"},
338+
"package": {"name", "needs", "runs-on", "timeout-minutes", "steps"},
339+
"standalone": {"name", "needs", "runs-on", "timeout-minutes", "steps"},
340+
}
287341
for name, job in (
288342
("quality", quality),
289343
("locked-runtime", locked_runtime),
@@ -304,6 +358,9 @@ def check_ci_workflow(text: str) -> None:
304358
_require_unconditional(step, f"CI latest-openai step {index}")
305359
if "env" in step:
306360
raise CheckError("CI latest-OpenAI steps must not override the CI environment")
361+
for name, value in jobs.items():
362+
job = _mapping(value, f"CI {name!r} job")
363+
_require_exact_keys(job, expected_job_keys[name], f"CI {name!r} job")
307364

308365
_require_needs(
309366
package,
@@ -358,6 +415,67 @@ def check_ci_workflow(text: str) -> None:
358415
"package": package,
359416
"standalone": standalone,
360417
}
418+
expected_step_names = {
419+
"quality": [
420+
"Check out the candidate",
421+
"Set up Python",
422+
"Install the pinned uv frontend",
423+
"Check lock consistency",
424+
"Reproduce the locked environment",
425+
"Lint",
426+
"Check formatting",
427+
"Type check",
428+
"Run offline unit and contract tests",
429+
"Check release version agreement",
430+
"Check canonical public content and identity",
431+
"Scan for credentials and scope mistakes",
432+
"Validate workflow syntax with checksum-pinned actionlint",
433+
"Verify release-workflow trust semantics",
434+
],
435+
"locked-runtime": [
436+
"Check out the candidate",
437+
"Set up Python",
438+
"Install the pinned uv frontend",
439+
"Reproduce the locked environment",
440+
"Run offline tests",
441+
],
442+
"minimum-openai": [
443+
"Check out the candidate",
444+
"Set up Python",
445+
"Install the pinned uv frontend",
446+
"Create the development environment",
447+
"Select the minimum supported OpenAI dependency",
448+
"Run offline tests without resyncing the lock",
449+
],
450+
"latest-openai": [
451+
"Check out the candidate",
452+
"Set up Python",
453+
"Install the pinned uv frontend",
454+
"Create the development environment",
455+
"Select latest OpenAI within the supported major",
456+
"Run canary tests without resyncing the lock",
457+
],
458+
"package": [
459+
"Check out the candidate",
460+
"Set up Python",
461+
"Install the pinned uv frontend",
462+
"Reproduce the locked environment",
463+
"Build wheel and source distribution",
464+
"Check package metadata rendering",
465+
"Inspect artifact identity and shape",
466+
"Install and smoke-test each exact artifact",
467+
"Record immutable artifact digests",
468+
"Retain verified artifacts",
469+
],
470+
"standalone": [
471+
"Check out the candidate",
472+
"Set up Python",
473+
"Install the pinned uv frontend",
474+
"Verify from a copied standalone repository",
475+
"Download the verified package artifacts",
476+
"Recheck retained artifact digests",
477+
],
478+
}
361479
expected_timeouts = {
362480
"quality": "20",
363481
"locked-runtime": "20",
@@ -382,6 +500,11 @@ def check_ci_workflow(text: str) -> None:
382500
_, setup_step = _named_action_step(
383501
job, "Set up Python", "actions/setup-python", f"CI {name} job"
384502
)
503+
_, checkout_step = _named_action_step(
504+
job, "Check out the candidate", "actions/checkout", f"CI {name} job"
505+
)
506+
if "with" in checkout_step:
507+
raise CheckError(f"CI {name} checkout must use the triggering candidate defaults")
385508
_require_options(
386509
setup_step,
387510
{"python-version": expected_python[name]},
@@ -446,13 +569,27 @@ def check_ci_workflow(text: str) -> None:
446569
"CI must finish copied-checkout verification before downloading and "
447570
"rechecking retained artifacts"
448571
)
449-
if _secret_references(workflow):
450-
raise CheckError("credential-free CI must not reference repository secrets")
572+
for name, job in required_jobs.items():
573+
_require_step_names(job, expected_step_names[name], f"CI {name} job")
574+
_require_step_working_directories(
575+
job,
576+
(
577+
{"Recheck retained artifact digests": "verified-artifacts"}
578+
if name == "standalone"
579+
else {}
580+
),
581+
f"CI {name} job",
582+
)
451583

452584

453585
def check_release_please_workflow(text: str) -> None:
454586
"""Require Release Please to remain explicitly disabled by default."""
455587
workflow = _load_workflow(text, "Release Please workflow")
588+
_require_exact_keys(
589+
workflow,
590+
{"name", "on", "permissions", "concurrency", "jobs"},
591+
"Release Please workflow",
592+
)
456593
_require_permissions(workflow, {"contents": "read"}, "Release Please workflow")
457594
if "env" in workflow:
458595
raise CheckError("Release Please workflow must not override the action environment")
@@ -478,6 +615,11 @@ def check_release_please_workflow(text: str) -> None:
478615
if set(jobs) != {"release-please"}:
479616
raise CheckError("Release Please must contain only its gated release-please job")
480617
release_job = _workflow_job(workflow, "release-please", "Release Please workflow")
618+
_require_exact_keys(
619+
release_job,
620+
{"name", "if", "runs-on", "timeout-minutes", "permissions", "steps"},
621+
"Release Please job",
622+
)
481623
if release_job.get("if") != "vars.RELEASE_PLEASE_ENABLED == 'true'":
482624
raise CheckError("Release Please must require RELEASE_PLEASE_ENABLED=true")
483625
if release_job.get("runs-on") != "ubuntu-latest":
@@ -503,6 +645,7 @@ def check_release_please_workflow(text: str) -> None:
503645
"Release Please job",
504646
)
505647
_require_step_environments(release_job, {}, "Release Please job")
648+
_require_step_working_directories(release_job, {}, "Release Please job")
506649
_, release_step = _named_action_step(
507650
release_job,
508651
"Open or update the release PR, or create its approved release",
@@ -525,6 +668,16 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None:
525668
"""Validate fail-closed publication, live, permission, and evidence ordering."""
526669
workflow = _load_workflow(text, "publish workflow")
527670
live_workflow = _load_workflow(live_smoke_text, "live-smoke workflow")
671+
_require_exact_keys(
672+
workflow,
673+
{"name", "on", "permissions", "concurrency", "env", "jobs"},
674+
"publish workflow",
675+
)
676+
_require_exact_keys(
677+
live_workflow,
678+
{"name", "on", "permissions", "concurrency", "env", "jobs"},
679+
"live-smoke workflow",
680+
)
528681

529682
publish_triggers = _mapping(workflow.get("on"), "publish workflow triggers")
530683
if set(publish_triggers) != {"release"}:
@@ -582,6 +735,11 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None:
582735
raise CheckError("release gates must not override command execution")
583736

584737
monitoring_job = _workflow_job(live_workflow, "smoke", "live-smoke workflow")
738+
_require_exact_keys(
739+
monitoring_job,
740+
{"name", "if", "runs-on", "timeout-minutes", "environment", "steps"},
741+
"monitoring live-smoke job",
742+
)
585743
monitoring_condition = " ".join(
586744
_scalar(monitoring_job.get("if"), "monitoring live-smoke condition").split()
587745
)
@@ -630,6 +788,7 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None:
630788
},
631789
"monitoring live-smoke job",
632790
)
791+
_require_step_working_directories(monitoring_job, {}, "monitoring live-smoke job")
633792
_, monitoring_checkout = _named_action_step(
634793
monitoring_job,
635794
"Check out the trusted default branch",
@@ -683,6 +842,49 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None:
683842
release_live = _workflow_job(workflow, "release-live-smoke", "publish workflow")
684843
publish = _workflow_job(workflow, "publish", "publish workflow")
685844
registry = _workflow_job(workflow, "verify-registry", "publish workflow")
845+
expected_release_job_keys = {
846+
"build": {"name", "runs-on", "timeout-minutes", "outputs", "permissions", "steps"},
847+
"release-live-smoke": {
848+
"name",
849+
"needs",
850+
"concurrency",
851+
"runs-on",
852+
"timeout-minutes",
853+
"permissions",
854+
"environment",
855+
"env",
856+
"steps",
857+
},
858+
"publish": {
859+
"name",
860+
"needs",
861+
"runs-on",
862+
"timeout-minutes",
863+
"environment",
864+
"permissions",
865+
"steps",
866+
},
867+
"verify-registry": {
868+
"name",
869+
"needs",
870+
"runs-on",
871+
"timeout-minutes",
872+
"permissions",
873+
"steps",
874+
},
875+
}
876+
for name, job in (
877+
("build", build),
878+
("release-live-smoke", release_live),
879+
("publish", publish),
880+
("verify-registry", registry),
881+
):
882+
_require_exact_keys(job, expected_release_job_keys[name], f"release {name} job")
883+
_require_step_working_directories(
884+
job,
885+
({"Recheck immutable artifact digests": "release-bundle"} if name == "publish" else {}),
886+
f"release {name} job",
887+
)
686888
for name, job, timeout in (
687889
("build", build, "25"),
688890
("release-live-smoke", release_live, "10"),
@@ -1133,6 +1335,20 @@ def workflow_paths(directory: Path) -> list[Path]:
11331335
return sorted(path for path in directory.iterdir() if path.suffix in {".yaml", ".yml"})
11341336

11351337

1338+
def check_workflow_inventory(directory: Path) -> list[Path]:
1339+
paths = workflow_paths(directory)
1340+
expected = {"ci.yml", "live-smoke.yml", "publish.yml", "release-please.yml"}
1341+
actual = {path.name for path in paths}
1342+
if actual != expected:
1343+
missing = ", ".join(sorted(expected - actual)) or "none"
1344+
unexpected = ", ".join(sorted(actual - expected)) or "none"
1345+
raise CheckError(
1346+
"workflow inventory does not match the reviewed contract "
1347+
f"(missing: {missing}; unexpected: {unexpected})"
1348+
)
1349+
return paths
1350+
1351+
11361352
def main() -> int:
11371353
parser = argparse.ArgumentParser(description=__doc__)
11381354
parser.add_argument(
@@ -1156,13 +1372,14 @@ def main() -> int:
11561372
default=PROJECT_ROOT / ".github" / "workflows" / "ci.yml",
11571373
)
11581374
args = parser.parse_args()
1375+
paths = check_workflow_inventory(args.ci_workflow.parent)
11591376
check_publish_workflow(
11601377
args.publish_workflow.read_text(encoding="utf-8"),
11611378
args.live_smoke_workflow.read_text(encoding="utf-8"),
11621379
)
11631380
check_release_please_workflow(args.release_please_workflow.read_text(encoding="utf-8"))
11641381
check_ci_workflow(args.ci_workflow.read_text(encoding="utf-8"))
1165-
for path in workflow_paths(args.ci_workflow.parent):
1382+
for path in paths:
11661383
check_action_pins(path.read_text(encoding="utf-8"), path.name)
11671384
print("release workflow semantic checks passed")
11681385
return 0

0 commit comments

Comments
 (0)