11#!/usr/bin/env python3
2- """Jenkins guard: forbid billable, automatically triggered GitHub Actions.
3-
4- Only manually dispatched or reusable workflows are permitted in this fork.
5- When importing upstream changes, keep this policy even if upstream adds CI.
6- """
2+ """Jenkins guard: allow only manual or reusable GitHub Actions workflows."""
73from pathlib import Path
8- import re
94import sys
105
6+ import yaml
7+ from yaml .events import AliasEvent , DocumentEndEvent , DocumentStartEvent , NodeEvent
8+ from yaml .nodes import MappingNode , ScalarNode
9+
1110ROOT = Path (__file__ ).resolve ().parents [2 ]
1211WORKFLOWS = ROOT / ".github" / "workflows"
1312ALLOWED = {"workflow_dispatch" , "workflow_call" }
14- ON = re .compile (r"""^(?:on|"on"|'on'):\s*(?:#.*)?$""" )
15- EVENT = re .compile (r"^ ([A-Za-z_][A-Za-z0-9_-]*):" )
13+
14+
15+ class PolicyLoader (yaml .SafeLoader ):
16+ # GitHub Actions uses YAML 1.2, where the unquoted key `on` is a string.
17+ yaml_implicit_resolvers = {
18+ key : [(tag , regexp ) for tag , regexp in resolvers
19+ if tag != "tag:yaml.org,2002:bool" ]
20+ for key , resolvers in yaml .SafeLoader .yaml_implicit_resolvers .items ()
21+ }
1622
1723def inspect (path : Path ) -> list [str ]:
18- lines = path .read_text (encoding = "utf-8" ).splitlines ()
19- triggers = []
20- found = False
21- for idx , line in enumerate (lines ):
22- if not ON .fullmatch (line ):
23- continue
24- if found :
25- return [f"{ path .name } : multiple on sections" ]
26- found = True
27- for sub in lines [idx + 1 :]:
28- if sub and not sub .startswith ((" " , "#" )):
29- break
30- match = EVENT .match (sub )
31- if match :
32- triggers .append (match .group (1 ))
33- if not found or not triggers :
34- return [f"{ path .name } : missing or non-block YAML on declaration" ]
24+ text = path .read_text (encoding = "utf-8" )
25+ try :
26+ for event in yaml .parse (text , Loader = PolicyLoader ):
27+ if isinstance (event , AliasEvent ) or (isinstance (event , NodeEvent ) and event .anchor ):
28+ raise ValueError ("anchors and aliases are unsupported" )
29+ if isinstance (event , DocumentStartEvent ) and (event .explicit or event .version or event .tags ):
30+ raise ValueError ("explicit YAML documents and directives are unsupported" )
31+ if isinstance (event , DocumentEndEvent ) and event .explicit :
32+ raise ValueError ("explicit YAML document endings are unsupported" )
33+ if isinstance (event , NodeEvent ) and event .tag :
34+ raise ValueError ("explicit YAML tags are unsupported" )
35+ root = yaml .compose (text , Loader = PolicyLoader )
36+ if not isinstance (root , MappingNode ):
37+ raise ValueError ("workflow document root must be a mapping" )
38+ on_nodes = [value for key , value in root .value
39+ if isinstance (key , ScalarNode ) and key .value == "on" ]
40+ if len (on_nodes ) != 1 :
41+ return [f"{ path .name } : expected exactly one top-level 'on' key" ]
42+ if not isinstance (on_nodes [0 ], MappingNode ):
43+ return [f"{ path .name } : top-level 'on' value must be a mapping" ]
44+ trigger_keys = []
45+ for key , _ in on_nodes [0 ].value :
46+ if not isinstance (key , ScalarNode ):
47+ raise ValueError ("trigger event names must be scalar strings" )
48+ if key .value in trigger_keys :
49+ return [f"{ path .name } : duplicate trigger event { key .value !r} " ]
50+ trigger_keys .append (key .value )
51+ docs = list (yaml .load_all (text , Loader = PolicyLoader ))
52+ except (yaml .YAMLError , ValueError ) as exc :
53+ return [f"{ path .name } : unsupported or invalid YAML: { exc } " ]
54+
55+ if len (docs ) != 1 or not isinstance (docs [0 ], dict ):
56+ return [f"{ path .name } : expected one YAML document with a workflow mapping" ]
57+ workflow = docs [0 ]
58+ if "on" not in workflow or not isinstance (workflow ["on" ], dict ) or not workflow ["on" ]:
59+ return [f"{ path .name } : missing or non-mapping 'on' trigger declaration" ]
60+ events = workflow ["on" ]
61+ if any (not isinstance (event , str ) for event in events ):
62+ return [f"{ path .name } : trigger event names must be strings" ]
3563 return [f"{ path .name } : forbidden GitHub Actions trigger { event !r} "
36- for event in triggers if event not in ALLOWED ]
64+ for event in events if event not in ALLOWED ]
65+
3766
3867def main () -> int :
3968 errors = []
@@ -49,5 +78,6 @@ def main() -> int:
4978 print ("GitHub Actions policy: manual/reusable only; Jenkins owns automatic CI" )
5079 return 0
5180
81+
5282if __name__ == "__main__" :
5383 sys .exit (main ())
0 commit comments