@@ -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+
4455def _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+
217244def _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
453585def 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+
11361352def 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