Skip to content

Commit c2ce6ce

Browse files
committed
feat: scaffold satellite repositories from the game-catalog layout
game-catalog took a day to get right: a validator that streams instead of loading ~1M records, a site that publishes counts rather than a listing, and a Pages environment that silently rejected main. new_satellite writes that layout from a handful of flags and prints the org-level steps it does not perform. The tests generate a repository and run its own suite and validator, so a template change that breaks generated repos fails here, not in a new repo.
1 parent aac7de4 commit c2ce6ce

15 files changed

Lines changed: 623 additions & 0 deletions

File tree

‎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/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())
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`).

‎machine/satellite_template/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.

‎machine/satellite_template/app/__init__.py‎

Whitespace-only changes.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Validate the {{title}}.
2+
3+
Generated by TechMachine's satellite template. Records are checked one at a
4+
time rather than loaded into a list first, so the check scales to catalogs of
5+
any size (game-catalog validates ~1M records in about 90 seconds on CI).
6+
7+
Run with: python -m app.validate
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import json
13+
import re
14+
import sys
15+
from pathlib import Path
16+
from typing import Any
17+
18+
ROOT = Path(__file__).resolve().parent.parent
19+
DATA = ROOT / "data" / "{{category}}"
20+
21+
REQUIRED = {"slug", "name", "source_urls", "verified"}
22+
DATE_FIELDS = {{date_fields}}
23+
RANGES = {{ranges}} # field -> (low, high), inclusive
24+
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
25+
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
26+
MAX_ERRORS = 200 # a wall of identical errors helps nobody
27+
28+
29+
def _check(rel: str, rec: dict[str, Any], errors: list[str]) -> None:
30+
missing = REQUIRED - rec.keys()
31+
if missing:
32+
errors.append(f"{rel}: missing required field(s) {sorted(missing)}")
33+
34+
slug = rec.get("slug")
35+
if isinstance(slug, str) and not SLUG_RE.match(slug):
36+
errors.append(f"{rel}: slug '{slug}' is not kebab-case")
37+
38+
urls = rec.get("source_urls")
39+
if not isinstance(urls, list) or not urls:
40+
errors.append(f"{rel}: source_urls must be a non-empty list")
41+
elif not all(isinstance(u, str) and u.startswith("http") for u in urls):
42+
errors.append(f"{rel}: every source_url must be an http(s) string")
43+
44+
for field in DATE_FIELDS:
45+
value = rec.get(field)
46+
if value is not None and not (isinstance(value, str) and DATE_RE.match(value)):
47+
errors.append(f"{rel}: {field} '{value}' is not YYYY-MM-DD")
48+
49+
for field, (low, high) in RANGES.items():
50+
value = rec.get(field)
51+
if value is None or isinstance(value, bool):
52+
continue
53+
if not isinstance(value, (int, float)) or not low <= value <= high:
54+
errors.append(f"{rel}: {field} {value!r} outside {low}-{high}")
55+
56+
57+
def validate(data_dir: Path = DATA) -> list[str]:
58+
errors: list[str] = []
59+
seen: dict[str, str] = {}
60+
count = 0
61+
62+
for path in sorted(data_dir.rglob("*.json")):
63+
rel = path.relative_to(data_dir.parent).as_posix()
64+
count += 1
65+
try:
66+
rec = json.loads(path.read_text(encoding="utf-8-sig"))
67+
except json.JSONDecodeError as exc:
68+
errors.append(f"{rel}: invalid JSON ({exc})")
69+
continue
70+
if not isinstance(rec, dict):
71+
errors.append(f"{rel}: top level must be an object")
72+
continue
73+
74+
_check(rel, rec, errors)
75+
76+
slug = rec.get("slug")
77+
if isinstance(slug, str):
78+
if slug in seen:
79+
errors.append(f"{rel}: duplicate slug '{slug}' (first seen in {seen[slug]})")
80+
else:
81+
seen[slug] = rel
82+
if path.stem != slug:
83+
errors.append(f"{rel}: filename does not match slug '{slug}'")
84+
85+
if len(errors) >= MAX_ERRORS:
86+
errors.append(f"... stopped after {MAX_ERRORS} errors")
87+
break
88+
89+
if count == 0:
90+
errors.append(f"no {{category}} records found under {data_dir}")
91+
return errors
92+
93+
94+
def main() -> int:
95+
errors = validate()
96+
for error in errors:
97+
print(error)
98+
if errors:
99+
print(f"FAIL: {len(errors)} problem(s)")
100+
return 1
101+
print("OK")
102+
return 0
103+
104+
105+
if __name__ == "__main__":
106+
sys.exit(main())

0 commit comments

Comments
 (0)