From 4999837ce311b38143814e6f479bd8dd151ef03b Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Thu, 29 Oct 2026 12:10:24 +0900 Subject: [PATCH 1/2] feat(health): report workflows that run under a personal token Read each active workflow file on every tracked branch and flag any secret other than GITHUB_TOKEN. Automation running under a personal token got a maintainer's account suspended; this makes it visible the day it returns. Disabled workflows are skipped, so turning one off in the Actions settings clears the finding. A repository that genuinely needs another secret lists it under allowed_secrets in repos.json. The tests also pin TechMachine's own workflows and the generated satellite workflows to GITHUB_TOKEN only. --- README.md | 7 +++++ machine/health.py | 52 +++++++++++++++++++++++++++++++++++++ tests/test_health.py | 28 ++++++++++++++++++++ tests/test_new_satellite.py | 8 ++++++ 4 files changed, 95 insertions(+) diff --git a/README.md b/README.md index e1f6e16..e98eac1 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,13 @@ 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`. + ```bash python -m machine.health # print the report python -m machine.health --issue # also update the issue (needs GITHUB_TOKEN) diff --git a/machine/health.py b/machine/health.py index 2601125..5ff9111 100644 --- a/machine/health.py +++ b/machine/health.py @@ -14,6 +14,7 @@ import argparse import json import os +import re import sys import urllib.error import urllib.request @@ -29,6 +30,12 @@ # `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 +52,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 +96,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 +143,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"]: diff --git a/tests/test_health.py b/tests/test_health.py index 053d825..5fdf151 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -84,3 +84,31 @@ 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) + + +# --- automation must not run as a person ----------------------------------- + +from pathlib import Path # noqa: E402 + +from machine.health import foreign_secrets, token_finding # noqa: E402 + +ROOT = Path(__file__).resolve().parent.parent + + +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_builtin_token_is_fine(): + assert token_finding("w", "GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}", "https://x") is None + + +def test_allowed_secret_is_fine(): + assert foreign_secrets("${{ secrets.SOME_API_KEY }}", {"SOME_API_KEY"}) == [] + + +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 From 4e15d949b405ea123bb344093caab0473ab2ca89 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Sat, 31 Oct 2026 23:16:11 +0900 Subject: [PATCH 2/2] ci(health): run only by hand and write nothing The health check no longer runs on a schedule, updates the Org health report issue, or mentions anyone. A person starts it from the Actions tab and the report goes to the run's step summary. An account here was suspended over automation, so nothing in this repository acts on its own. The workflow keeps GITHUB_TOKEN with read-only permissions. Every action in health.yml and test.yml is a GitHub-owned action pinned by full SHA. TechAPI's label-triggered final check is the one sanctioned use of TechEngineBot's token, so it is allowed for that repository only. --- .github/workflows/health.yml | 12 +++---- .github/workflows/test.yml | 7 ++-- README.md | 20 ++++++------ machine/health.py | 62 ++++-------------------------------- machine/repos.json | 4 ++- tests/test_health.py | 33 ------------------- 6 files changed, 30 insertions(+), 108 deletions(-) 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 e98eac1..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. @@ -25,21 +25,21 @@ 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`. +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 5ff9111..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 @@ -24,7 +28,6 @@ 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. @@ -178,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") @@ -240,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 5fdf151..94ac5a7 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -53,39 +53,6 @@ def test_render_all_clear_and_problem_table(): assert "1 problem" in problems and "| cancelled |" in problems -# --- alerts ----------------------------------------------------------------- - -from machine.health import Finding, alert_needed, fingerprint # noqa: E402 - -BROKEN = [Finding("TechEngine@main · weekly-refresh", "cancelled")] - - -def test_new_problem_alerts(): - assert alert_needed("", fingerprint(BROKEN)) - - -def test_same_problem_twice_does_not_alert_again(): - body = f"report\n\n" - assert not alert_needed(body, fingerprint(BROKEN)) - - -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_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) - - # --- automation must not run as a person ----------------------------------- from pathlib import Path # noqa: E402