diff --git a/.github/workflows/health.yml b/.github/workflows/health.yml index ee7f2f6..4799410 100644 --- a/.github/workflows/health.yml +++ b/.github/workflows/health.yml @@ -1,13 +1,13 @@ name: health +# Manual only. Automation under a personal account got it suspended, so +# nothing here runs on a schedule or writes to GitHub: the report goes to +# this run's step summary. on: - schedule: - - cron: "0 0 * * *" # daily, 09:00 KST workflow_dispatch: permissions: contents: read - issues: write # keeps the single "Org health report" issue current actions: read concurrency: @@ -19,11 +19,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.12" - name: Check every repo and public endpoint env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: python -m machine.health --issue + run: python -m machine.health diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e925edb..d37a388 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,13 +5,16 @@ on: push: branches: [develop, main] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.12" - run: | diff --git a/README.md b/README.md index e1f6e16..12b6021 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ TechMachine looks after the repositories that hold it. ## Health check -`machine/health.py` runs daily and keeps one issue, **Org health report**, -up to date. For every repository in [`machine/repos.json`](machine/repos.json) +`machine/health.py` runs when a person starts it — the `health` workflow's +*Run workflow* button, or locally. For every repository in [`machine/repos.json`](machine/repos.json) it reads the newest completed run of each active workflow on each tracked branch, and it fetches the public endpoints the TechAPI homepage depends on. @@ -20,19 +20,26 @@ It queries each workflow separately rather than scanning "the last N runs", because weekly jobs scroll out of any fixed window behind the daily ones — and the weekly jobs are the ones that go quiet. +It also reads each active workflow file and reports any that uses a secret +other than `GITHUB_TOKEN`. Automation here must act as `github-actions[bot]`, +never as a person: workflows running under a personal token are how a +maintainer's account got suspended. Disabling the workflow in the Actions +settings clears the finding; a non-token secret a repository genuinely needs +goes in that repository's `allowed_secrets` in `repos.json` — TechAPI's +label-triggered final check is the one place TechEngineBot's token is allowed. + +It only reads. The report goes to stdout and the run's step summary; it +writes nothing to GitHub, mentions nobody, and runs on no schedule — an +account here was suspended over automation, and a report nobody asked for is +how that starts. + ```bash -python -m machine.health # print the report -python -m machine.health --issue # also update the issue (needs GITHUB_TOKEN) +python -m machine.health # print the report (GITHUB_TOKEN raises the rate limit) python -m pytest -q ``` Adding a repository or endpoint is one entry in `machine/repos.json`. -When the set of problems changes, the check comments on the issue and -@-mentions `notify` from that file — editing an issue body notifies nobody. -It stays quiet when the same failure recurs (the fingerprint is -workflow + result, not the run link) and never pings for an all-clear. - ## Satellite repositories Categories that do not belong in TechAPI live in their own repository (games: diff --git a/machine/health.py b/machine/health.py index 2601125..554c4fd 100644 --- a/machine/health.py +++ b/machine/health.py @@ -6,7 +6,11 @@ by an environment rule leaves no log at all. Each of those went unnoticed for days. This treats anything other than success/skipped as a problem. -Run with: python -m machine.health [--issue] +It only reads. The report goes to stdout and the run's step summary; nothing +is written to GitHub and nobody is mentioned, so it runs only when a person +starts it. + +Run with: python -m machine.health """ from __future__ import annotations @@ -14,6 +18,7 @@ import argparse import json import os +import re import sys import urllib.error import urllib.request @@ -23,12 +28,17 @@ API = "https://api.github.com" CONFIG = Path(__file__).with_name("repos.json") -ISSUE_TITLE = "Org health report" # Conclusions that mean the run did its job. Everything else is surfaced — # `cancelled` and `timed_out` included, because that is how silent failures look. HEALTHY = {"success", "skipped", "neutral"} +# Automation must act as github-actions[bot], never as a person. Any other +# secret in an active workflow is usually a personal token, and automation +# running under one got a maintainer's account suspended. +SECRET = re.compile(r"secrets\.(\w+)") +BUILTIN_TOKEN = "GITHUB_TOKEN" + @dataclass class Finding: @@ -45,6 +55,26 @@ def _get(url: str, token: str | None) -> Any: return json.loads(response.read().decode("utf-8")) +def _get_text(url: str, token: str | None) -> str: + request = urllib.request.Request(url, headers={"Accept": "application/vnd.github.raw+json"}) + if token: + request.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(request, timeout=30) as response: + return response.read().decode("utf-8") + + +def foreign_secrets(workflow_text: str, allowed: set[str] = frozenset()) -> list[str]: + """Secrets a workflow uses other than the built-in token (and any allowed).""" + return sorted(set(SECRET.findall(workflow_text)) - {BUILTIN_TOKEN} - allowed) + + +def token_finding(where: str, workflow_text: str, url: str, allowed: set[str] = frozenset()) -> Finding | None: + names = foreign_secrets(workflow_text, allowed) + if not names: + return None + return Finding(where, f"uses {', '.join(names)} — automation must not run as a person", url) + + def latest_runs(runs: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: """Newest completed run per workflow name. `runs` arrive newest first.""" newest: dict[str, dict[str, Any]] = {} @@ -69,10 +99,34 @@ def endpoint_finding(name: str, url: str, expect: str, body: Any) -> Finding | N return None +def workflow_token_findings(repo: str, branch: str, workflow: dict[str, Any], + token: str | None, allowed: set[str]) -> list[Finding]: + """Flag an active workflow that reads a secret other than GITHUB_TOKEN. + + Only active workflows count: disabling one in the Actions settings is + enough to stop it acting, and clears the finding. + """ + path = workflow.get("path", "") + if not path.startswith(".github/workflows/"): + return [] # dynamic workflows such as pages-build-deployment have no file + where = f"{repo}@{branch} · {workflow['name']}" + try: + text = _get_text(f"{API}/repos/{repo}/contents/{path}?ref={branch}", token) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return [] # the file is not on this branch + return [Finding(where, f"could not read workflow file: {exc}")] + except urllib.error.URLError as exc: + return [Finding(where, f"could not read workflow file: {exc}")] + finding = token_finding(where, text, f"https://github.com/{repo}/blob/{branch}/{path}", allowed) + return [finding] if finding else [] + + def check(config: dict[str, Any], token: str | None) -> list[Finding]: findings: list[Finding] = [] for repo in config["repos"]: name = repo["name"] + allowed = set(repo.get("allowed_secrets", [])) try: workflows = _get(f"{API}/repos/{name}/actions/workflows?per_page=100", token) except urllib.error.URLError as exc: @@ -92,6 +146,7 @@ def check(config: dict[str, Any], token: str | None) -> list[Finding]: runs.extend(_get(url, token).get("workflow_runs", [])) except urllib.error.URLError as exc: findings.append(Finding(f"{name} · {workflow['name']}", f"could not read runs: {exc}")) + findings.extend(workflow_token_findings(name, branch, workflow, token, allowed)) findings.extend(run_findings(name, branch, runs)) for endpoint in config["endpoints"]: @@ -126,58 +181,8 @@ def render(findings: list[Finding], config: dict[str, Any]) -> str: return "\n".join(lines) + "\n" -def fingerprint(findings: list[Finding]) -> str: - """Stable identity of a problem set: what is broken, not when it was checked.""" - return ",".join(sorted(f"{f.where}={f.what}" for f in findings)) - - -def alert_needed(previous_body: str, current: str) -> bool: - """Alert only when the set of problems changed — and never for all clear. - - A daily ping about the same known failure trains people to ignore the - ping; the point is to hear about the *new* one. - """ - if not current: - return False - return f"" not in previous_body - - -def publish(body: str, token: str, repo: str, findings: list[Finding], mention: str) -> None: - """Keep one open issue up to date instead of opening one per run.""" - def call(method: str, path: str, payload: dict[str, Any] | None = None) -> Any: - data = json.dumps(payload).encode("utf-8") if payload is not None else None - request = urllib.request.Request(f"{API}{path}", data=data, method=method, headers={ - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - }) - with urllib.request.urlopen(request, timeout=30) as response: - return json.loads(response.read().decode("utf-8") or "null") - - current = fingerprint(findings) - stamped = f"{body}\n\n" - - issues = call("GET", f"/repos/{repo}/issues?state=open&per_page=100") - existing = next((i for i in issues if i.get("title") == ISSUE_TITLE), None) - if existing: - number = existing["number"] - previous = existing.get("body") or "" - call("PATCH", f"/repos/{repo}/issues/{number}", {"body": stamped}) - else: - number = call("POST", f"/repos/{repo}/issues", {"title": ISSUE_TITLE, "body": stamped})["number"] - previous = "" - - # A comment, not an edit: editing an issue body notifies nobody. - if mention and alert_needed(previous, current): - lines = "\n".join(f"- {f.where}: **{f.what}**" for f in findings) - call("POST", f"/repos/{repo}/issues/{number}/comments", - {"body": f"@{mention} the org health report changed:\n\n{lines}"}) - - def main() -> int: - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument("--issue", action="store_true", help="update the health issue") - args = parser.parse_args() + argparse.ArgumentParser(description=__doc__.splitlines()[0]).parse_args() config = json.loads(CONFIG.read_text(encoding="utf-8")) token = os.environ.get("GITHUB_TOKEN") @@ -188,9 +193,6 @@ def main() -> int: summary = os.environ.get("GITHUB_STEP_SUMMARY") if summary: Path(summary).write_text(report, encoding="utf-8") - if args.issue and token: - publish(report, token, os.environ.get("GITHUB_REPOSITORY", "GetTechAPI/TechMachine"), - findings, config.get("notify", "")) # The report is the output; a red run would only add a second alert. return 0 diff --git a/machine/repos.json b/machine/repos.json index 659e22b..2dbdea7 100644 --- a/machine/repos.json +++ b/machine/repos.json @@ -1,11 +1,13 @@ { - "notify": "Seungpyo1007", "repos": [ { "name": "GetTechAPI/TechAPI", "branches": [ "develop", "main" + ], + "allowed_secrets": [ + "TECHENGINEBOT_TOKEN" ] }, { diff --git a/tests/test_health.py b/tests/test_health.py index 053d825..94ac5a7 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -53,34 +53,29 @@ def test_render_all_clear_and_problem_table(): assert "1 problem" in problems and "| cancelled |" in problems -# --- alerts ----------------------------------------------------------------- +# --- automation must not run as a person ----------------------------------- -from machine.health import Finding, alert_needed, fingerprint # noqa: E402 +from pathlib import Path # noqa: E402 -BROKEN = [Finding("TechEngine@main · weekly-refresh", "cancelled")] +from machine.health import foreign_secrets, token_finding # noqa: E402 +ROOT = Path(__file__).resolve().parent.parent -def test_new_problem_alerts(): - assert alert_needed("", fingerprint(BROKEN)) +def test_personal_token_is_a_problem(): + text = "token: ${{ secrets.TECHENGINEBOT_TOKEN || secrets.TECHAPI_TOKEN }}" + finding = token_finding("org/repo@main · weekly-ingest", text, "https://x") + assert finding and "TECHAPI_TOKEN" in finding.what and "TECHENGINEBOT_TOKEN" in finding.what -def test_same_problem_twice_does_not_alert_again(): - body = f"report\n\n" - assert not alert_needed(body, fingerprint(BROKEN)) +def test_builtin_token_is_fine(): + assert token_finding("w", "GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}", "https://x") is None -def test_a_different_problem_alerts(): - body = f"report\n\n" - worse = BROKEN + [Finding("TechAPI@main · deploy-pages", "failure")] - assert alert_needed(body, fingerprint(worse)) +def test_allowed_secret_is_fine(): + assert foreign_secrets("${{ secrets.SOME_API_KEY }}", {"SOME_API_KEY"}) == [] -def test_all_clear_never_alerts(): - body = f"report\n\n" - assert not alert_needed(body, fingerprint([])) - -def test_fingerprint_ignores_order_and_links(): - a = [Finding("x", "failure", "https://run/1"), Finding("y", "cancelled", "https://run/2")] - b = [Finding("y", "cancelled", "https://run/9"), Finding("x", "failure", "https://run/8")] - assert fingerprint(a) == fingerprint(b) +def test_techmachine_itself_uses_only_the_builtin_token(): + for path in sorted((ROOT / ".github" / "workflows").glob("*.yml")): + assert foreign_secrets(path.read_text(encoding="utf-8")) == [], path diff --git a/tests/test_new_satellite.py b/tests/test_new_satellite.py index b3c6ac4..a705484 100644 --- a/tests/test_new_satellite.py +++ b/tests/test_new_satellite.py @@ -58,3 +58,11 @@ def test_refuses_to_overwrite(generated: Path): def test_unknown_placeholder_is_an_error(): with pytest.raises(KeyError): render("{{nope}}", {}) + + +def test_generated_workflows_use_only_the_builtin_token(generated: Path): + from machine.health import foreign_secrets + workflows = sorted((generated / ".github" / "workflows").glob("*.yml")) + assert workflows + for path in workflows: + assert foreign_secrets(path.read_text(encoding="utf-8")) == [], path