|
22 | 22 | ROOT / ".github" / "workflows" / "quality-contract.yml", |
23 | 23 | ) |
24 | 24 | MERGE_SMOKE_WORKFLOW = ROOT / ".github" / "workflows" / "ci-reusable-pilot.yml" |
| 25 | +MERGE_SMOKE_CALLEE = ROOT / ".github" / "workflows" / "ci-python-local.yml" |
25 | 26 | PR_HEAD_REF = "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}" |
26 | 27 | TASK_PREFIX_RE = re.compile(r"`([A-Za-z0-9_-]+)/<task>`") |
27 | 28 |
|
@@ -63,28 +64,84 @@ def _push_branches(path: Path) -> set[str]: |
63 | 64 | raise ValueError(f"{path}: push trigger has no branches list") |
64 | 65 |
|
65 | 66 |
|
| 67 | +def _yaml_code(line: str) -> str: |
| 68 | + """Return the structural part of a simple repository workflow line.""" |
| 69 | + return line.split("#", 1)[0].rstrip() |
| 70 | + |
| 71 | + |
66 | 72 | def _pull_request_is_unfiltered(path: Path) -> bool: |
67 | | - """Return True iff pull_request exists and has no base-branch filter.""" |
| 73 | + """Require default PR activity coverage with no base/path suppression.""" |
68 | 74 | lines = path.read_text(encoding="utf-8").splitlines() |
69 | 75 | pr_index = next( |
70 | | - (i for i, line in enumerate(lines) if line.rstrip() == " pull_request:"), |
| 76 | + ( |
| 77 | + i |
| 78 | + for i, line in enumerate(lines) |
| 79 | + if _yaml_code(line).rstrip() == " pull_request:" |
| 80 | + ), |
71 | 81 | None, |
72 | 82 | ) |
73 | 83 | if pr_index is None: |
74 | 84 | return False |
| 85 | + |
| 86 | + forbidden = ("branches:", "branches-ignore:", "paths:", "paths-ignore:", "types:") |
75 | 87 | for line in lines[pr_index + 1 :]: |
76 | | - if line and not line.startswith(" "): |
| 88 | + code = _yaml_code(line) |
| 89 | + if not code.strip(): |
| 90 | + continue |
| 91 | + indent = len(code) - len(code.lstrip(" ")) |
| 92 | + if indent < 4: |
77 | 93 | break |
78 | | - stripped = line.strip() |
79 | | - if stripped.startswith("branches:") or stripped.startswith("branches-ignore:"): |
| 94 | + stripped = code.strip() |
| 95 | + if stripped.startswith(forbidden): |
80 | 96 | return False |
81 | 97 | return True |
82 | 98 |
|
83 | 99 |
|
| 100 | +def _checkout_ref_values(path: Path) -> list[str | None]: |
| 101 | + """Return with.ref for every actions/checkout step, None if absent.""" |
| 102 | + lines = path.read_text(encoding="utf-8").splitlines() |
| 103 | + refs: list[str | None] = [] |
| 104 | + for i, line in enumerate(lines): |
| 105 | + code = _yaml_code(line) |
| 106 | + stripped = code.strip() |
| 107 | + if not stripped.startswith("- uses: actions/checkout@"): |
| 108 | + continue |
| 109 | + step_indent = len(code) - len(code.lstrip(" ")) |
| 110 | + in_with = False |
| 111 | + ref_value: str | None = None |
| 112 | + for next_line in lines[i + 1 :]: |
| 113 | + next_code = _yaml_code(next_line) |
| 114 | + if not next_code.strip(): |
| 115 | + continue |
| 116 | + indent = len(next_code) - len(next_code.lstrip(" ")) |
| 117 | + next_stripped = next_code.strip() |
| 118 | + if indent <= step_indent: |
| 119 | + break |
| 120 | + if indent == step_indent + 2: |
| 121 | + in_with = next_stripped == "with:" |
| 122 | + continue |
| 123 | + if in_with and indent >= step_indent + 4 and next_stripped.startswith("ref:"): |
| 124 | + ref_value = next_stripped.split(":", 1)[1].strip().strip(chr(34)).strip(chr(39)) |
| 125 | + refs.append(ref_value) |
| 126 | + return refs |
| 127 | + |
| 128 | + |
84 | 129 | def _checks_out_exact_pr_head(path: Path) -> bool: |
85 | | - """Require PR jobs to checkout the submitted head SHA, not only merge ref.""" |
86 | | - text = path.read_text(encoding="utf-8") |
87 | | - return f"ref: {PR_HEAD_REF}" in text |
| 130 | + """Every checkout in an exact-head workflow must bind with.ref.""" |
| 131 | + refs = _checkout_ref_values(path) |
| 132 | + return bool(refs) and all(ref == PR_HEAD_REF for ref in refs) |
| 133 | + |
| 134 | + |
| 135 | +def _merge_smoke_keeps_default_merge_ref() -> bool: |
| 136 | + """Pilot calls known callee; its checkout must omit with.ref.""" |
| 137 | + if not MERGE_SMOKE_WORKFLOW.exists() or not MERGE_SMOKE_CALLEE.exists(): |
| 138 | + return False |
| 139 | + caller = MERGE_SMOKE_WORKFLOW.read_text(encoding="utf-8") |
| 140 | + if "uses: ./.github/workflows/ci-python-local.yml" not in caller: |
| 141 | + return False |
| 142 | + refs = _checkout_ref_values(MERGE_SMOKE_CALLEE) |
| 143 | + return bool(refs) and all(ref is None for ref in refs) |
| 144 | + |
88 | 145 |
|
89 | 146 | def main() -> int: |
90 | 147 | errors: list[str] = [] |
@@ -132,7 +189,13 @@ def main() -> int: |
132 | 189 | elif not _pull_request_is_unfiltered(MERGE_SMOKE_WORKFLOW): |
133 | 190 | errors.append( |
134 | 191 | f"{MERGE_SMOKE_WORKFLOW.relative_to(ROOT)}: merge-smoke pull_request " |
135 | | - "must cover arbitrary base branches" |
| 192 | + "must cover arbitrary base branches and default PR activity types" |
| 193 | + ) |
| 194 | + if not _merge_smoke_keeps_default_merge_ref(): |
| 195 | + errors.append( |
| 196 | + "merge-smoke contract drifted: ci-reusable-pilot.yml must call " |
| 197 | + "ci-python-local.yml and that callee actions/checkout step must " |
| 198 | + "omit with.ref so the synthetic PR merge ref is exercised" |
136 | 199 | ) |
137 | 200 |
|
138 | 201 | if errors: |
|
0 commit comments