Skip to content

Commit fcc1db1

Browse files
committed
feat: org health check
Reads the newest completed run of every active workflow in TechAPI, TechEngine and the satellite repos, plus the public endpoints the TechAPI homepage depends on, and keeps one issue up to date. cancelled and timed_out count as problems: a job killed at GitHub's six-hour limit reports itself as cancelled, which is how weekly-refresh failed on 2026-09-14 without anyone noticing. The first run found it.
0 parents  commit fcc1db1

10 files changed

Lines changed: 361 additions & 0 deletions

File tree

‎.github/workflows/health.yml‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: health
2+
3+
on:
4+
schedule:
5+
- cron: "0 0 * * *" # daily, 09:00 KST
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
issues: write # keeps the single "Org health report" issue current
11+
actions: read
12+
13+
concurrency:
14+
group: health
15+
cancel-in-progress: true
16+
17+
jobs:
18+
check:
19+
runs-on: ubuntu-latest
20+
timeout-minutes: 10
21+
steps:
22+
- uses: actions/checkout@v4
23+
- uses: actions/setup-python@v5
24+
with:
25+
python-version: "3.12"
26+
- name: Check every repo and public endpoint
27+
env:
28+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29+
run: python -m machine.health --issue

‎.github/workflows/test.yml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: test
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches: [develop, main]
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
timeout-minutes: 10
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: actions/setup-python@v5
15+
with:
16+
python-version: "3.12"
17+
- run: |
18+
pip install pytest
19+
python -m pytest -q

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
__pycache__/
2+
*.py[cod]
3+
.venv/
4+
.idea/
5+
.vscode/
6+
.DS_Store

‎LICENSE‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 GTA Foundation
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎README.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# TechMachine
2+
3+
Repository management for the GetTechAPI organisation. Where
4+
[TechEngine](https://github.com/GetTechAPI/TechEngine) processes the data,
5+
TechMachine looks after the repositories that hold it.
6+
7+
## Health check
8+
9+
`machine/health.py` runs daily and keeps one issue, **Org health report**,
10+
up to date. For every repository in [`machine/repos.json`](machine/repos.json)
11+
it reads the newest completed run of each active workflow on each tracked
12+
branch, and it fetches the public endpoints the TechAPI homepage depends on.
13+
14+
Anything other than `success`, `skipped` or `neutral` is reported —
15+
`cancelled` and `timed_out` included. That is deliberate: a job killed at
16+
GitHub's six-hour limit reports itself as *cancelled*, and several automations
17+
here failed that way for days without anyone noticing.
18+
19+
It queries each workflow separately rather than scanning "the last N runs",
20+
because weekly jobs scroll out of any fixed window behind the daily ones — and
21+
the weekly jobs are the ones that go quiet.
22+
23+
```bash
24+
python -m machine.health # print the report
25+
python -m machine.health --issue # also update the issue (needs GITHUB_TOKEN)
26+
python -m pytest -q
27+
```
28+
29+
Adding a repository or endpoint is one entry in `machine/repos.json`.
30+
31+
## Branching
32+
33+
`develop` is the default branch; `main` is the released state. Pull requests
34+
target `develop`.
35+
36+
## License
37+
38+
MIT ([LICENSE](LICENSE)).

‎machine/__init__.py‎

Whitespace-only changes.

‎machine/health.py‎

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""Org health check: the newest run of every workflow, plus the public endpoints.
2+
3+
Why this exists: automations here fail quietly. A job that hits GitHub's 6-hour
4+
ceiling is reported as *cancelled*, not failed; a workflow that does all its
5+
work and dies at `git push` looks like nothing happened; a Pages deploy blocked
6+
by an environment rule leaves no log at all. Each of those went unnoticed for
7+
days. This treats anything other than success/skipped as a problem.
8+
9+
Run with: python -m machine.health [--issue]
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import argparse
15+
import json
16+
import os
17+
import sys
18+
import urllib.error
19+
import urllib.request
20+
from dataclasses import dataclass
21+
from pathlib import Path
22+
from typing import Any
23+
24+
API = "https://api.github.com"
25+
CONFIG = Path(__file__).with_name("repos.json")
26+
ISSUE_TITLE = "Org health report"
27+
28+
# Conclusions that mean the run did its job. Everything else is surfaced —
29+
# `cancelled` and `timed_out` included, because that is how silent failures look.
30+
HEALTHY = {"success", "skipped", "neutral"}
31+
32+
33+
@dataclass
34+
class Finding:
35+
where: str
36+
what: str
37+
url: str = ""
38+
39+
40+
def _get(url: str, token: str | None) -> Any:
41+
request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"})
42+
if token and url.startswith(API):
43+
request.add_header("Authorization", f"Bearer {token}")
44+
with urllib.request.urlopen(request, timeout=30) as response:
45+
return json.loads(response.read().decode("utf-8"))
46+
47+
48+
def latest_runs(runs: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
49+
"""Newest completed run per workflow name. `runs` arrive newest first."""
50+
newest: dict[str, dict[str, Any]] = {}
51+
for run in runs:
52+
if run.get("status") != "completed":
53+
continue
54+
newest.setdefault(run["name"], run)
55+
return newest
56+
57+
58+
def run_findings(repo: str, branch: str, runs: list[dict[str, Any]]) -> list[Finding]:
59+
return [
60+
Finding(f"{repo}@{branch} · {name}", run.get("conclusion") or "unknown", run.get("html_url", ""))
61+
for name, run in sorted(latest_runs(runs).items())
62+
if run.get("conclusion") not in HEALTHY
63+
]
64+
65+
66+
def endpoint_finding(name: str, url: str, expect: str, body: Any) -> Finding | None:
67+
if not isinstance(body, dict) or expect not in body:
68+
return Finding(name, f"response has no `{expect}` field", url)
69+
return None
70+
71+
72+
def check(config: dict[str, Any], token: str | None) -> list[Finding]:
73+
findings: list[Finding] = []
74+
for repo in config["repos"]:
75+
name = repo["name"]
76+
try:
77+
workflows = _get(f"{API}/repos/{name}/actions/workflows?per_page=100", token)
78+
except urllib.error.URLError as exc:
79+
findings.append(Finding(name, f"could not list workflows: {exc}"))
80+
continue
81+
for branch in repo["branches"]:
82+
# One query per workflow, not "the last N runs": a weekly job would
83+
# scroll out of any fixed window behind the daily ones, and weekly
84+
# jobs are exactly the ones that fail unnoticed.
85+
runs: list[dict[str, Any]] = []
86+
for workflow in workflows.get("workflows", []):
87+
if workflow.get("state") != "active":
88+
continue
89+
url = (f"{API}/repos/{name}/actions/workflows/{workflow['id']}/runs"
90+
f"?branch={branch}&status=completed&per_page=1")
91+
try:
92+
runs.extend(_get(url, token).get("workflow_runs", []))
93+
except urllib.error.URLError as exc:
94+
findings.append(Finding(f"{name} · {workflow['name']}", f"could not read runs: {exc}"))
95+
findings.extend(run_findings(name, branch, runs))
96+
97+
for endpoint in config["endpoints"]:
98+
try:
99+
body = _get(endpoint["url"], None)
100+
except (urllib.error.URLError, json.JSONDecodeError) as exc:
101+
findings.append(Finding(endpoint["name"], f"unreachable: {exc}", endpoint["url"]))
102+
continue
103+
finding = endpoint_finding(endpoint["name"], endpoint["url"], endpoint["expect"], body)
104+
if finding:
105+
findings.append(finding)
106+
return findings
107+
108+
109+
def render(findings: list[Finding], config: dict[str, Any]) -> str:
110+
checked = ", ".join(repo["name"].split("/")[1] for repo in config["repos"])
111+
if not findings:
112+
return f"## Org health: all clear\n\nChecked {checked} and {len(config['endpoints'])} public endpoints.\n"
113+
lines = [
114+
f"## Org health: {len(findings)} problem(s)",
115+
"",
116+
f"Checked {checked} and {len(config['endpoints'])} public endpoints. "
117+
"`cancelled` and `timed_out` are listed on purpose: that is how a job "
118+
"killed at a time limit reports itself.",
119+
"",
120+
"| Where | Result |",
121+
"|---|---|",
122+
]
123+
for f in findings:
124+
where = f"[{f.where}]({f.url})" if f.url else f.where
125+
lines.append(f"| {where} | {f.what} |")
126+
return "\n".join(lines) + "\n"
127+
128+
129+
def publish(body: str, token: str, repo: str) -> None:
130+
"""Keep one open issue up to date instead of opening one per run."""
131+
def call(method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
132+
data = json.dumps(payload).encode("utf-8") if payload is not None else None
133+
request = urllib.request.Request(f"{API}{path}", data=data, method=method, headers={
134+
"Accept": "application/vnd.github+json",
135+
"Authorization": f"Bearer {token}",
136+
"Content-Type": "application/json",
137+
})
138+
with urllib.request.urlopen(request, timeout=30) as response:
139+
return json.loads(response.read().decode("utf-8") or "null")
140+
141+
issues = call("GET", f"/repos/{repo}/issues?state=open&per_page=100")
142+
existing = next((i for i in issues if i.get("title") == ISSUE_TITLE), None)
143+
if existing:
144+
call("PATCH", f"/repos/{repo}/issues/{existing['number']}", {"body": body})
145+
else:
146+
call("POST", f"/repos/{repo}/issues", {"title": ISSUE_TITLE, "body": body})
147+
148+
149+
def main() -> int:
150+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
151+
parser.add_argument("--issue", action="store_true", help="update the health issue")
152+
args = parser.parse_args()
153+
154+
config = json.loads(CONFIG.read_text(encoding="utf-8"))
155+
token = os.environ.get("GITHUB_TOKEN")
156+
findings = check(config, token)
157+
report = render(findings, config)
158+
print(report)
159+
160+
summary = os.environ.get("GITHUB_STEP_SUMMARY")
161+
if summary:
162+
Path(summary).write_text(report, encoding="utf-8")
163+
if args.issue and token:
164+
publish(report, token, os.environ.get("GITHUB_REPOSITORY", "GetTechAPI/TechMachine"))
165+
# The report is the output; a red run would only add a second alert.
166+
return 0
167+
168+
169+
if __name__ == "__main__":
170+
sys.exit(main())

‎machine/repos.json‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"repos": [
3+
{"name": "GetTechAPI/TechAPI", "branches": ["develop", "main"]},
4+
{"name": "GetTechAPI/TechEngine", "branches": ["main"]},
5+
{"name": "GetTechAPI/game-catalog", "branches": ["develop", "main"]},
6+
{"name": "GetTechAPI/cpu-engineering-samples", "branches": ["develop", "main"]}
7+
],
8+
"endpoints": [
9+
{"name": "TechAPI manifest", "url": "https://gettechapi.github.io/TechAPI/v1/index.json", "expect": "collections"},
10+
{"name": "game-catalog summary", "url": "https://gettechapi.github.io/game-catalog/summary.json", "expect": "count"},
11+
{"name": "cpu-engineering-samples summary", "url": "https://gettechapi.github.io/cpu-engineering-samples/summary.json", "expect": "count"}
12+
]
13+
}

‎pyproject.toml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[project]
2+
name = "techmachine"
3+
version = "0.1.0"
4+
description = "Repository management for the GetTechAPI organisation"
5+
requires-python = ">=3.11"
6+
7+
[project.optional-dependencies]
8+
dev = ["pytest>=8.0"]
9+
10+
[tool.pytest.ini_options]
11+
testpaths = ["tests"]
12+
pythonpath = ["."]

‎tests/test_health.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""The checks that matter: silent failures surface, stale failures do not."""
2+
3+
from __future__ import annotations
4+
5+
from machine.health import endpoint_finding, latest_runs, render, run_findings
6+
7+
8+
def _run(name: str, conclusion: str | None, status: str = "completed") -> dict:
9+
return {"name": name, "status": status, "conclusion": conclusion, "html_url": f"https://x/{name}"}
10+
11+
12+
def test_cancelled_is_a_problem():
13+
# A job killed at GitHub's 6-hour limit reports `cancelled`, not `failure`.
14+
findings = run_findings("org/repo", "main", [_run("dump-refresh", "cancelled")])
15+
assert [f.what for f in findings] == ["cancelled"]
16+
17+
18+
def test_timed_out_is_a_problem():
19+
assert run_findings("org/repo", "main", [_run("deploy", "timed_out")])
20+
21+
22+
def test_success_and_skipped_are_healthy():
23+
runs = [_run("a", "success"), _run("b", "skipped")]
24+
assert run_findings("org/repo", "main", runs) == []
25+
26+
27+
def test_only_the_newest_run_per_workflow_counts():
28+
# Newest first, as the API returns them: a fixed failure is not reported.
29+
runs = [_run("deploy", "success"), _run("deploy", "failure")]
30+
assert run_findings("org/repo", "main", runs) == []
31+
32+
33+
def test_a_new_failure_after_a_success_is_reported():
34+
runs = [_run("deploy", "failure"), _run("deploy", "success")]
35+
assert len(run_findings("org/repo", "main", runs)) == 1
36+
37+
38+
def test_in_progress_runs_are_ignored():
39+
runs = [_run("dump", None, status="in_progress"), _run("dump", "success")]
40+
assert latest_runs(runs)["dump"]["conclusion"] == "success"
41+
42+
43+
def test_endpoint_without_expected_field_is_a_problem():
44+
assert endpoint_finding("summary", "https://x", "count", {"other": 1})
45+
assert endpoint_finding("summary", "https://x", "count", "<html>404</html>")
46+
assert endpoint_finding("summary", "https://x", "count", {"count": 147}) is None
47+
48+
49+
def test_render_all_clear_and_problem_table():
50+
config = {"repos": [{"name": "org/repo"}], "endpoints": [{}]}
51+
assert "all clear" in render([], config)
52+
problems = render(run_findings("org/repo", "main", [_run("deploy", "cancelled")]), config)
53+
assert "1 problem" in problems and "| cancelled |" in problems

0 commit comments

Comments
 (0)