Skip to content

Commit 7f03972

Browse files
authored
Merge pull request #4 from GetTechAPI/develop
release: alerts + satellite scaffold
2 parents fcc1db1 + 3f13af7 commit 7f03972

18 files changed

Lines changed: 732 additions & 11 deletions

‎README.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,35 @@ python -m pytest -q
2828

2929
Adding a repository or endpoint is one entry in `machine/repos.json`.
3030

31+
When the set of problems changes, the check comments on the issue and
32+
@-mentions `notify` from that file — editing an issue body notifies nobody.
33+
It stays quiet when the same failure recurs (the fingerprint is
34+
workflow + result, not the run link) and never pings for an all-clear.
35+
36+
## Satellite repositories
37+
38+
Categories that do not belong in TechAPI live in their own repository (games:
39+
[game-catalog](https://github.com/GetTechAPI/game-catalog)). The split
40+
criterion is identity, not size — software and websites are tech data and
41+
stay in TechAPI.
42+
43+
`machine/new_satellite.py` writes a new one from the layout game-catalog
44+
proved out: a streaming validator, a site build that publishes
45+
`summary.json` + `history.json` (never a listing of every record), CI, and
46+
licences.
47+
48+
```bash
49+
python -m machine.new_satellite --repo game-catalog --category game --title "Game catalog" --plural games --date-field release_date --range rating:0:5 --range metacritic:0:100 --out ../game-catalog
50+
```
51+
52+
It only writes files. Creating the repository changes the organisation, so
53+
that is left to a person; the remaining steps are printed at the end,
54+
including adding `main` to the Pages environment's deployment branches —
55+
without it every deploy fails and leaves no log.
56+
57+
The tests generate a repository and run *its* test suite and validator, so a
58+
template change that breaks generated repos fails here.
59+
3160
## Branching
3261

3362
`develop` is the default branch; `main` is the released state. Pull requests

‎machine/health.py‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,23 @@ def render(findings: list[Finding], config: dict[str, Any]) -> str:
126126
return "\n".join(lines) + "\n"
127127

128128

129-
def publish(body: str, token: str, repo: str) -> None:
129+
def fingerprint(findings: list[Finding]) -> str:
130+
"""Stable identity of a problem set: what is broken, not when it was checked."""
131+
return ",".join(sorted(f"{f.where}={f.what}" for f in findings))
132+
133+
134+
def alert_needed(previous_body: str, current: str) -> bool:
135+
"""Alert only when the set of problems changed — and never for all clear.
136+
137+
A daily ping about the same known failure trains people to ignore the
138+
ping; the point is to hear about the *new* one.
139+
"""
140+
if not current:
141+
return False
142+
return f"<!-- fingerprint:{current} -->" not in previous_body
143+
144+
145+
def publish(body: str, token: str, repo: str, findings: list[Finding], mention: str) -> None:
130146
"""Keep one open issue up to date instead of opening one per run."""
131147
def call(method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
132148
data = json.dumps(payload).encode("utf-8") if payload is not None else None
@@ -138,12 +154,24 @@ def call(method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
138154
with urllib.request.urlopen(request, timeout=30) as response:
139155
return json.loads(response.read().decode("utf-8") or "null")
140156

157+
current = fingerprint(findings)
158+
stamped = f"{body}\n<!-- fingerprint:{current} -->\n"
159+
141160
issues = call("GET", f"/repos/{repo}/issues?state=open&per_page=100")
142161
existing = next((i for i in issues if i.get("title") == ISSUE_TITLE), None)
143162
if existing:
144-
call("PATCH", f"/repos/{repo}/issues/{existing['number']}", {"body": body})
163+
number = existing["number"]
164+
previous = existing.get("body") or ""
165+
call("PATCH", f"/repos/{repo}/issues/{number}", {"body": stamped})
145166
else:
146-
call("POST", f"/repos/{repo}/issues", {"title": ISSUE_TITLE, "body": body})
167+
number = call("POST", f"/repos/{repo}/issues", {"title": ISSUE_TITLE, "body": stamped})["number"]
168+
previous = ""
169+
170+
# A comment, not an edit: editing an issue body notifies nobody.
171+
if mention and alert_needed(previous, current):
172+
lines = "\n".join(f"- {f.where}: **{f.what}**" for f in findings)
173+
call("POST", f"/repos/{repo}/issues/{number}/comments",
174+
{"body": f"@{mention} the org health report changed:\n\n{lines}"})
147175

148176

149177
def main() -> int:
@@ -161,7 +189,8 @@ def main() -> int:
161189
if summary:
162190
Path(summary).write_text(report, encoding="utf-8")
163191
if args.issue and token:
164-
publish(report, token, os.environ.get("GITHUB_REPOSITORY", "GetTechAPI/TechMachine"))
192+
publish(report, token, os.environ.get("GITHUB_REPOSITORY", "GetTechAPI/TechMachine"),
193+
findings, config.get("notify", ""))
165194
# The report is the output; a red run would only add a second alert.
166195
return 0
167196

‎machine/new_satellite.py‎

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Scaffold a satellite data repository from the proven game-catalog layout.
2+
3+
Writes the files only. Creating the GitHub repository is left to a person —
4+
it is an org-level change — and the remaining one-off steps are printed at
5+
the end, including the one that silently broke game-catalog's first deploys.
6+
7+
Example:
8+
python -m machine.new_satellite --repo game-catalog --category game \
9+
--title "Game catalog" --plural games --date-field release_date \
10+
--range rating:0:5 --range metacritic:0:100 --out ../game-catalog
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import argparse
16+
import re
17+
import sys
18+
from pathlib import Path
19+
20+
TEMPLATE = Path(__file__).with_name("satellite_template")
21+
PLACEHOLDER = re.compile(r"\{\{(\w+)\}\}")
22+
# Template files stored under a name git or pytest would otherwise act on.
23+
RENAMES = {"gitignore": ".gitignore", "tests_test_validate.py": "tests/test_validate.py"}
24+
25+
26+
def parse_range(text: str) -> tuple[str, float, float]:
27+
field, low, high = text.split(":")
28+
return field, float(low), float(high)
29+
30+
31+
def render(text: str, values: dict[str, str]) -> str:
32+
def replace(match: re.Match[str]) -> str:
33+
key = match.group(1)
34+
if key not in values:
35+
raise KeyError(f"template placeholder {{{{{key}}}}} has no value")
36+
return values[key]
37+
return PLACEHOLDER.sub(replace, text)
38+
39+
40+
def values_from(args: argparse.Namespace) -> dict[str, str]:
41+
ranges = {field: (low, high) for field, low, high in args.range}
42+
return {
43+
"repo": args.repo,
44+
"category": args.category,
45+
"title": args.title,
46+
"plural": args.plural,
47+
"description": args.description,
48+
"date_fields": repr(tuple(args.date_field)),
49+
"ranges": repr({k: (int(a) if a.is_integer() else a, int(b) if b.is_integer() else b)
50+
for k, (a, b) in ranges.items()}),
51+
}
52+
53+
54+
def scaffold(out: Path, values: dict[str, str]) -> list[Path]:
55+
if out.exists() and any(out.iterdir()):
56+
raise SystemExit(f"{out} is not empty; refusing to overwrite")
57+
written = []
58+
for source in sorted(TEMPLATE.rglob("*")):
59+
if source.is_dir():
60+
continue
61+
rel = source.relative_to(TEMPLATE).as_posix()
62+
target = out / RENAMES.get(rel, rel)
63+
target.parent.mkdir(parents=True, exist_ok=True)
64+
if source.suffix in {".py", ".md", ".toml", ".yml", ".html", ""} or source.name == "gitignore":
65+
target.write_text(render(source.read_text(encoding="utf-8"), values),
66+
encoding="utf-8", newline="\n")
67+
else:
68+
target.write_bytes(source.read_bytes())
69+
written.append(target)
70+
(out / "data" / values["category"]).mkdir(parents=True, exist_ok=True)
71+
(out / "README.md").write_text(readme(values), encoding="utf-8", newline="\n")
72+
written.append(out / "README.md")
73+
return written
74+
75+
76+
def readme(v: dict[str, str]) -> str:
77+
return f"""# {v['repo']}
78+
79+
[![validate-data](https://github.com/GetTechAPI/{v['repo']}/actions/workflows/validate-data.yml/badge.svg)](https://github.com/GetTechAPI/{v['repo']}/actions/workflows/validate-data.yml)
80+
81+
{v['description']}
82+
83+
Code is MIT; the records under `data/` are CC BY-SA 4.0 ([DATA_LICENSE.md](DATA_LICENSE.md)).
84+
85+
## Layout
86+
87+
```
88+
data/{v['category']}/<bucket>/<slug>.json # bucket = first two slug characters
89+
app/validate.py # schema / slug / date / range checks
90+
site/build.py # summary.json + history.json
91+
```
92+
93+
A record needs `slug`, `name`, `source_urls` and `verified`.
94+
95+
## Self-check
96+
97+
```bash
98+
python -m app.validate
99+
python -m pytest -q
100+
```
101+
102+
## Site
103+
104+
`python site/build.py` writes `summary.json` (`{{"count": N}}`) and
105+
`history.json` (one point per data commit). The TechAPI homepage reads these
106+
to count this catalog; there is deliberately no listing of every record.
107+
108+
## Branching (git-flow)
109+
110+
`develop` is the default branch; `main` is the released state and deploys the
111+
site. Pull requests target `develop`; a release is a PR from `develop` to `main`.
112+
"""
113+
114+
115+
def checklist(v: dict[str, str]) -> str:
116+
repo = f"GetTechAPI/{v['repo']}"
117+
return f"""
118+
Next steps (not automated — each changes the org):
119+
120+
1. gh repo create {repo} --public
121+
2. push the scaffold to develop, then develop:main
122+
3. gh api -X POST repos/{repo}/pages -f build_type=workflow
123+
4. gh api -X POST repos/{repo}/environments/github-pages/deployment-branch-policies -f name=main
124+
(skip this and every deploy fails with no log — game-catalog, 2026-09-17)
125+
5. add {{"name": "{repo}", "branches": ["develop", "main"]}} to TechMachine machine/repos.json
126+
and the summary.json URL to its endpoints
127+
6. add {{ key: "{v['plural']}", label: "{v['plural']}", base: "https://gettechapi.github.io/{v['repo']}/" }}
128+
to SATELLITES in TechAPI site/src/scripts/techapi.js
129+
"""
130+
131+
132+
def main(argv: list[str] | None = None) -> int:
133+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
134+
parser.add_argument("--repo", required=True, help="repository name, e.g. game-catalog")
135+
parser.add_argument("--category", required=True, help="data directory, e.g. game")
136+
parser.add_argument("--title", required=True, help='page title, e.g. "Game catalog"')
137+
parser.add_argument("--plural", required=True, help="count label, e.g. games")
138+
parser.add_argument("--description", default="Split out of TechAPI.")
139+
parser.add_argument("--date-field", action="append", default=[], help="YYYY-MM-DD field, repeatable")
140+
parser.add_argument("--range", action="append", default=[], type=parse_range,
141+
help="field:low:high, repeatable")
142+
parser.add_argument("--out", type=Path, required=True)
143+
args = parser.parse_args(argv)
144+
145+
values = values_from(args)
146+
written = scaffold(args.out, values)
147+
print(f"wrote {len(written)} files to {args.out}")
148+
print(checklist(values))
149+
return 0
150+
151+
152+
if __name__ == "__main__":
153+
sys.exit(main())

‎machine/repos.json‎

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,49 @@
11
{
2+
"notify": "Seungpyo1007",
23
"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"]}
4+
{
5+
"name": "GetTechAPI/TechAPI",
6+
"branches": [
7+
"develop",
8+
"main"
9+
]
10+
},
11+
{
12+
"name": "GetTechAPI/TechEngine",
13+
"branches": [
14+
"main"
15+
]
16+
},
17+
{
18+
"name": "GetTechAPI/game-catalog",
19+
"branches": [
20+
"develop",
21+
"main"
22+
]
23+
},
24+
{
25+
"name": "GetTechAPI/cpu-engineering-samples",
26+
"branches": [
27+
"develop",
28+
"main"
29+
]
30+
}
731
],
832
"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"}
33+
{
34+
"name": "TechAPI manifest",
35+
"url": "https://gettechapi.github.io/TechAPI/v1/index.json",
36+
"expect": "collections"
37+
},
38+
{
39+
"name": "game-catalog summary",
40+
"url": "https://gettechapi.github.io/game-catalog/summary.json",
41+
"expect": "count"
42+
},
43+
{
44+
"name": "cpu-engineering-samples summary",
45+
"url": "https://gettechapi.github.io/cpu-engineering-samples/summary.json",
46+
"expect": "count"
47+
}
1248
]
1349
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: deploy-pages
2+
3+
on:
4+
push:
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
pages: write
11+
id-token: write
12+
13+
concurrency:
14+
group: pages
15+
cancel-in-progress: true
16+
17+
jobs:
18+
build:
19+
runs-on: ubuntu-latest
20+
timeout-minutes: 60
21+
steps:
22+
- uses: actions/checkout@v4
23+
with:
24+
fetch-depth: 0 # history.json replays every data commit
25+
- uses: actions/setup-python@v5
26+
with:
27+
python-version: "3.12"
28+
- name: Build summary + history
29+
run: python site/build.py
30+
- uses: actions/upload-pages-artifact@v3
31+
with:
32+
path: site
33+
34+
deploy:
35+
needs: build
36+
runs-on: ubuntu-latest
37+
environment:
38+
name: github-pages
39+
url: ${{ steps.deployment.outputs.page_url }}
40+
steps:
41+
- id: deployment
42+
uses: actions/deploy-pages@v4
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: validate-data
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches: [develop, main]
7+
8+
jobs:
9+
validate:
10+
runs-on: ubuntu-latest
11+
timeout-minutes: 90 # 962k files; measure a real run before trimming
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: actions/setup-python@v5
15+
with:
16+
python-version: "3.12"
17+
- name: Validate {{title}}
18+
run: python -m app.validate
19+
- name: Tests
20+
run: |
21+
pip install pytest
22+
python -m pytest -q
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Data license
2+
3+
JSON records under `data/` are licensed under
4+
[Creative Commons Attribution-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-sa/4.0/).
5+
6+
Attribute **"Data from GetTechAPI / {{repo}}"** and share alike.
7+
8+
Validator, site, and workflow code remain MIT (see `LICENSE`).

0 commit comments

Comments
 (0)