Skip to content

Commit 301da6d

Browse files
fix(#159): harden trigger scope, scalar parsing and evidence reachability
1 parent 365a930 commit 301da6d

1 file changed

Lines changed: 27 additions & 7 deletions

File tree

.github/scripts/check_agent_branch_triggers.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@
3131
PR_HEAD_REF = "${{ github.event.pull_request.head.sha }}"
3232
PR_ONLY_IF = "${{ github.event_name == 'pull_request' }}"
3333
TASK_PREFIX_RE = re.compile(r"`([A-Za-z0-9_-]+)/<task>`")
34-
BLOCK_SCALAR_RE = re.compile(r"[:=-]\s*[|>][+-]?\s*$")
34+
BLOCK_SCALAR_RE = re.compile(r"[:=-]\s*[|>](?:[1-9][+-]?|[+-][1-9]?|)\s*$")
3535

3636

3737
def _display_path(path: Path) -> str:
3838
try:
39-
return str(_display_path(path))
39+
return str(path.relative_to(ROOT))
4040
except ValueError:
4141
return str(path)
4242

@@ -71,8 +71,21 @@ def _structural_lines(path: Path) -> list[tuple[int, int, str]]:
7171
scalar_indent = indent
7272
return out
7373

74-
def _push_branches(path: Path) -> set[str]:
74+
def _on_block_lines(path: Path) -> list[tuple[int, int, str]]:
75+
"""Return only children of the top-level on mapping."""
7576
lines = _structural_lines(path)
77+
for i, (_, indent, stripped) in enumerate(lines):
78+
if indent == 0 and stripped == "on:":
79+
out: list[tuple[int, int, str]] = []
80+
for item in lines[i + 1 :]:
81+
if item[1] == 0:
82+
break
83+
out.append(item)
84+
return out
85+
return []
86+
87+
def _push_branches(path: Path) -> set[str]:
88+
lines = _on_block_lines(path)
7689
for i, (_, indent, stripped) in enumerate(lines):
7790
if indent == 2 and stripped == "push:":
7891
for _, child_indent, child in lines[i + 1 :]:
@@ -90,16 +103,16 @@ def _push_branches(path: Path) -> set[str]:
90103
if item.strip()
91104
}
92105
raise ValueError(f"{path}: push trigger has no branches list")
93-
raise ValueError(f"{path}: missing top-level push trigger")
106+
raise ValueError(f"{path}: missing top-level on.push trigger")
94107

95108
def _yaml_code(line: str) -> str:
96109
"""Return the structural part of a simple repository workflow line."""
97110
return line.split("#", 1)[0].rstrip()
98111

99112

100113
def _pull_request_is_unfiltered(path: Path) -> bool:
101-
"""Require default PR activity coverage with no base/path suppression."""
102-
lines = _structural_lines(path)
114+
"""Require top-level on.pull_request with no suppression filters."""
115+
lines = _on_block_lines(path)
103116
forbidden = ("branches:", "branches-ignore:", "paths:", "paths-ignore:", "types:")
104117
for i, (_, indent, stripped) in enumerate(lines):
105118
if indent == 2 and stripped == "pull_request:":
@@ -154,13 +167,16 @@ def _job_specs(path: Path) -> dict[str, dict[str, object]]:
154167
continue
155168
if indent == 2 and stripped.endswith(":") and not stripped.startswith("-"):
156169
current = stripped[:-1]
157-
jobs[current] = {"if": None, "uses": [], "refs": []}
170+
jobs[current] = {"if": None, "needs": None, "uses": [], "refs": []}
158171
continue
159172
if current is None:
160173
continue
161174
if indent == 4 and stripped.startswith("if:"):
162175
jobs[current]["if"] = stripped.split(":", 1)[1].strip()
163176
continue
177+
if indent == 4 and stripped.startswith("needs:"):
178+
jobs[current]["needs"] = stripped.split(":", 1)[1].strip()
179+
continue
164180
if indent == 4 and stripped.startswith("uses:"):
165181
uses = jobs[current]["uses"]
166182
assert isinstance(uses, list)
@@ -202,6 +218,8 @@ def _evidence_job_errors(path: Path) -> list[str]:
202218
else:
203219
if required["if"] is not None:
204220
errors.append(f"{_display_path(path)}:{required_id}: required job must not be conditional")
221+
if required["needs"] is not None:
222+
errors.append(f"{_display_path(path)}:{required_id}: required job must not depend on another job")
205223
if required["refs"] != [None]:
206224
errors.append(f"{_display_path(path)}:{required_id}: required job must have one default-ref checkout")
207225
head = jobs.get(head_id)
@@ -210,6 +228,8 @@ def _evidence_job_errors(path: Path) -> list[str]:
210228
else:
211229
if head["if"] != PR_ONLY_IF:
212230
errors.append(f"{_display_path(path)}:{head_id}: exact-head job must be PR-only")
231+
if head["needs"] is not None:
232+
errors.append(f"{_display_path(path)}:{head_id}: exact-head job must not depend on another job")
213233
if head["refs"] != [PR_HEAD_REF]:
214234
errors.append(f"{_display_path(path)}:{head_id}: exact-head checkout is not bound to PR head")
215235
return errors

0 commit comments

Comments
 (0)