workflow_lint.py python
156 lines 6.0 KB
Raw
sha256:0e9549ec7b463911bc08b7d586dc320b1ac9b1f5c943ee7e3865dcc6cb0f6f83 chore(governance): sync handover+roadmap to 84db8c8 (drift:… Human 1 day ago
1 """Static analysis helpers for desktop-release workflow YAML (§QR.4 / §QR.13)."""
2
3 from __future__ import annotations
4
5 from pathlib import Path
6 from typing import Any
7
8 import yaml
9
10 from tools.desktop_release.constants import (
11 ALL_RELEASE_SECRET_NAMES,
12 LINUX_PINNED_RUNNERS,
13 MACOS_PINNED_RUNNERS,
14 )
15 from tools.desktop_release.refuse import scan_text_for_secret_patterns
16
17 FORBIDDEN_PERMISSION_KEYS = frozenset(
18 {
19 "actions",
20 "pull-requests",
21 "repository-projects",
22 "issues",
23 "discussions",
24 "packages",
25 "pages",
26 "security-events",
27 "deployments",
28 "id-token", # OIDC write omitted by default (§QR.4.6)
29 }
30 )
31
32 ELEVATED_PERMISSION_VALUES = frozenset({"write", "admin"})
33
34
35 def load_workflow(path: Path) -> dict[str, Any]:
36 """Parse a GitHub Actions workflow YAML file.
37
38 PyYAML historically maps the unquoted key ``on`` to boolean ``True``;
39 normalize that key back to the string ``"on"`` after load.
40 """
41 text = path.read_text(encoding="utf-8")
42 data = yaml.safe_load(text)
43 if not isinstance(data, dict):
44 raise ValueError(f"workflow root must be a mapping: {path}")
45 if True in data and "on" not in data:
46 data["on"] = data.pop(True)
47 return data
48
49
50 def workflow_triggers(data: dict[str, Any]) -> set[str]:
51 """Return top-level ``on`` trigger keys."""
52 on = data.get("on")
53 if on is None and True in data:
54 on = data[True]
55 if isinstance(on, str):
56 return {on}
57 if isinstance(on, list):
58 return {str(item) for item in on}
59 if isinstance(on, dict):
60 return {str(key) for key in on.keys()}
61 raise ValueError("workflow missing on: triggers")
62
63 def matrix_runners(data: dict[str, Any]) -> list[str]:
64 """Collect ``runs-on`` values from all jobs (including matrix expansions)."""
65 runners: list[str] = []
66 jobs = data.get("jobs") or {}
67 for job in jobs.values():
68 if not isinstance(job, dict):
69 continue
70 runs_on = job.get("runs-on")
71 strategy = job.get("strategy") or {}
72 matrix = strategy.get("matrix") if isinstance(strategy, dict) else None
73 if isinstance(matrix, dict):
74 matrix_runners_list = matrix.get("os") or matrix.get("runner") or matrix.get("runs-on")
75 if isinstance(matrix_runners_list, list):
76 runners.extend(str(item) for item in matrix_runners_list)
77 continue
78 include = matrix.get("include")
79 if isinstance(include, list):
80 for row in include:
81 if isinstance(row, dict):
82 for key in ("os", "runner", "runs-on"):
83 if key in row:
84 runners.append(str(row[key]))
85 break
86 continue
87 if runs_on is not None:
88 runners.append(str(runs_on))
89 return runners
90
91
92 def assert_release_workflow_contract(data: dict[str, Any]) -> None:
93 """Assert frozen release-workflow rules (§QR.4.2–§QR.4.6)."""
94 triggers = workflow_triggers(data)
95 forbidden = triggers & {"pull_request", "schedule"}
96 if forbidden:
97 raise AssertionError(f"release workflow forbids triggers: {sorted(forbidden)}")
98 if "push" not in triggers and "workflow_dispatch" not in triggers:
99 raise AssertionError("release workflow must allow tag push and/or workflow_dispatch")
100
101 perms = data.get("permissions")
102 if not isinstance(perms, dict):
103 raise AssertionError("release workflow must declare permissions")
104 if perms.get("contents") != "write":
105 raise AssertionError("permissions.contents must be write")
106 for key, value in perms.items():
107 if key == "contents":
108 continue
109 if key in FORBIDDEN_PERMISSION_KEYS and str(value) in ELEVATED_PERMISSION_VALUES:
110 raise AssertionError(f"forbidden elevated permission: {key}: {value}")
111 if key == "id-token":
112 raise AssertionError("id-token must be omitted by default (§QR.4.6)")
113
114 runners = matrix_runners(data)
115 has_macos = any(r in MACOS_PINNED_RUNNERS for r in runners)
116 has_windows = any("windows" in r for r in runners)
117 has_linux = any(r in LINUX_PINNED_RUNNERS or r.startswith("ubuntu-") for r in runners)
118 if not (has_macos and has_windows and has_linux):
119 raise AssertionError(f"matrix must cover macos/windows/linux; got {runners}")
120 for runner in runners:
121 if runner == "macos-latest":
122 raise AssertionError("macos-latest is forbidden; pin macos-14 or macos-15")
123 if runner == "ubuntu-latest":
124 raise AssertionError("prefer pinned ubuntu-22.04/24.04 over ubuntu-latest")
125
126
127 def assert_smoke_workflow_contract(data: dict[str, Any]) -> None:
128 """Smoke workflow must not publish GitHub Releases."""
129 text = yaml.dump(data)
130 lowered = text.lower()
131 if "softprops/action-gh-release" in lowered:
132 raise AssertionError("smoke workflow must not use action-gh-release")
133 if "gh release upload" in lowered or "gh release create" in lowered:
134 raise AssertionError("smoke workflow must not upload GitHub Releases")
135 triggers = workflow_triggers(data)
136 if "pull_request" not in triggers and "workflow_dispatch" not in triggers:
137 raise AssertionError("smoke workflow should run on pull_request and/or workflow_dispatch")
138
139
140 def workflow_references_secret_names(text: str, names: frozenset[str] | None = None) -> set[str]:
141 """Return which §QR.6.2 secret names appear as ``secrets.NAME`` references."""
142 wanted = names or ALL_RELEASE_SECRET_NAMES
143 found: set[str] = set()
144 for name in wanted:
145 if f"secrets.{name}" in text:
146 found.add(name)
147 return found
148
149
150 def assert_workflow_text_clean(path: Path) -> None:
151 """Fail if workflow file embeds private-key / password literals."""
152 text = path.read_text(encoding="utf-8")
153 hits = scan_text_for_secret_patterns(text)
154 # Allow secret *references* — scan_text already skips secrets. context for PFX.
155 if hits:
156 raise AssertionError(f"{path}: secret patterns found: {hits}")
File History 1 commit
sha256:6abcf1fa82a7a621ccbc945f19acdba5bc0db54569599404a1452fb4a096a199 fix(ISR): default require_independent_second_reviewer to require Human minor 1 day ago