workflow_lint.py
python
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