Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/health.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
7 changes: 5 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
25 changes: 16 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand Down
114 changes: 58 additions & 56 deletions machine/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@
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

import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
Expand All @@ -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:
Expand All @@ -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]] = {}
Expand All @@ -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:
Expand All @@ -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"]:
Expand Down Expand Up @@ -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"<!-- fingerprint:{current} -->" 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<!-- fingerprint:{current} -->\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")
Expand All @@ -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

Expand Down
4 changes: 3 additions & 1 deletion machine/repos.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
{
"notify": "Seungpyo1007",
"repos": [
{
"name": "GetTechAPI/TechAPI",
"branches": [
"develop",
"main"
],
"allowed_secrets": [
"TECHENGINEBOT_TOKEN"
]
},
{
Expand Down
35 changes: 15 additions & 20 deletions tests/test_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<!-- fingerprint:{fingerprint(BROKEN)} -->\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<!-- fingerprint:{fingerprint(BROKEN)} -->\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<!-- fingerprint:{fingerprint(BROKEN)} -->\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
8 changes: 8 additions & 0 deletions tests/test_new_satellite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading