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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,35 @@ 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:
[game-catalog](https://github.com/GetTechAPI/game-catalog)). The split
criterion is identity, not size — software and websites are tech data and
stay in TechAPI.

`machine/new_satellite.py` writes a new one from the layout game-catalog
proved out: a streaming validator, a site build that publishes
`summary.json` + `history.json` (never a listing of every record), CI, and
licences.

```bash
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
```

It only writes files. Creating the repository changes the organisation, so
that is left to a person; the remaining steps are printed at the end,
including adding `main` to the Pages environment's deployment branches —
without it every deploy fails and leaves no log.

The tests generate a repository and run *its* test suite and validator, so a
template change that breaks generated repos fails here.

## Branching

`develop` is the default branch; `main` is the released state. Pull requests
Expand Down
37 changes: 33 additions & 4 deletions machine/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,23 @@ def render(findings: list[Finding], config: dict[str, Any]) -> str:
return "\n".join(lines) + "\n"


def publish(body: str, token: str, repo: str) -> None:
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
Expand All @@ -138,12 +154,24 @@ def call(method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
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:
call("PATCH", f"/repos/{repo}/issues/{existing['number']}", {"body": body})
number = existing["number"]
previous = existing.get("body") or ""
call("PATCH", f"/repos/{repo}/issues/{number}", {"body": stamped})
else:
call("POST", f"/repos/{repo}/issues", {"title": ISSUE_TITLE, "body": body})
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:
Expand All @@ -161,7 +189,8 @@ def main() -> int:
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"))
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
153 changes: 153 additions & 0 deletions machine/new_satellite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Scaffold a satellite data repository from the proven game-catalog layout.

Writes the files only. Creating the GitHub repository is left to a person —
it is an org-level change — and the remaining one-off steps are printed at
the end, including the one that silently broke game-catalog's first deploys.

Example:
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
"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

TEMPLATE = Path(__file__).with_name("satellite_template")
PLACEHOLDER = re.compile(r"\{\{(\w+)\}\}")
# Template files stored under a name git or pytest would otherwise act on.
RENAMES = {"gitignore": ".gitignore", "tests_test_validate.py": "tests/test_validate.py"}


def parse_range(text: str) -> tuple[str, float, float]:
field, low, high = text.split(":")
return field, float(low), float(high)


def render(text: str, values: dict[str, str]) -> str:
def replace(match: re.Match[str]) -> str:
key = match.group(1)
if key not in values:
raise KeyError(f"template placeholder {{{{{key}}}}} has no value")
return values[key]
return PLACEHOLDER.sub(replace, text)


def values_from(args: argparse.Namespace) -> dict[str, str]:
ranges = {field: (low, high) for field, low, high in args.range}
return {
"repo": args.repo,
"category": args.category,
"title": args.title,
"plural": args.plural,
"description": args.description,
"date_fields": repr(tuple(args.date_field)),
"ranges": repr({k: (int(a) if a.is_integer() else a, int(b) if b.is_integer() else b)
for k, (a, b) in ranges.items()}),
}


def scaffold(out: Path, values: dict[str, str]) -> list[Path]:
if out.exists() and any(out.iterdir()):
raise SystemExit(f"{out} is not empty; refusing to overwrite")
written = []
for source in sorted(TEMPLATE.rglob("*")):
if source.is_dir():
continue
rel = source.relative_to(TEMPLATE).as_posix()
target = out / RENAMES.get(rel, rel)
target.parent.mkdir(parents=True, exist_ok=True)
if source.suffix in {".py", ".md", ".toml", ".yml", ".html", ""} or source.name == "gitignore":
target.write_text(render(source.read_text(encoding="utf-8"), values),
encoding="utf-8", newline="\n")
else:
target.write_bytes(source.read_bytes())
written.append(target)
(out / "data" / values["category"]).mkdir(parents=True, exist_ok=True)
(out / "README.md").write_text(readme(values), encoding="utf-8", newline="\n")
written.append(out / "README.md")
return written


def readme(v: dict[str, str]) -> str:
return f"""# {v['repo']}

[![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)

{v['description']}

Code is MIT; the records under `data/` are CC BY-SA 4.0 ([DATA_LICENSE.md](DATA_LICENSE.md)).

## Layout

```
data/{v['category']}/<bucket>/<slug>.json # bucket = first two slug characters
app/validate.py # schema / slug / date / range checks
site/build.py # summary.json + history.json
```

A record needs `slug`, `name`, `source_urls` and `verified`.

## Self-check

```bash
python -m app.validate
python -m pytest -q
```

## Site

`python site/build.py` writes `summary.json` (`{{"count": N}}`) and
`history.json` (one point per data commit). The TechAPI homepage reads these
to count this catalog; there is deliberately no listing of every record.

## Branching (git-flow)

`develop` is the default branch; `main` is the released state and deploys the
site. Pull requests target `develop`; a release is a PR from `develop` to `main`.
"""


def checklist(v: dict[str, str]) -> str:
repo = f"GetTechAPI/{v['repo']}"
return f"""
Next steps (not automated — each changes the org):

1. gh repo create {repo} --public
2. push the scaffold to develop, then develop:main
3. gh api -X POST repos/{repo}/pages -f build_type=workflow
4. gh api -X POST repos/{repo}/environments/github-pages/deployment-branch-policies -f name=main
(skip this and every deploy fails with no log — game-catalog, 2026-09-17)
5. add {{"name": "{repo}", "branches": ["develop", "main"]}} to TechMachine machine/repos.json
and the summary.json URL to its endpoints
6. add {{ key: "{v['plural']}", label: "{v['plural']}", base: "https://gettechapi.github.io/{v['repo']}/" }}
to SATELLITES in TechAPI site/src/scripts/techapi.js
"""


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--repo", required=True, help="repository name, e.g. game-catalog")
parser.add_argument("--category", required=True, help="data directory, e.g. game")
parser.add_argument("--title", required=True, help='page title, e.g. "Game catalog"')
parser.add_argument("--plural", required=True, help="count label, e.g. games")
parser.add_argument("--description", default="Split out of TechAPI.")
parser.add_argument("--date-field", action="append", default=[], help="YYYY-MM-DD field, repeatable")
parser.add_argument("--range", action="append", default=[], type=parse_range,
help="field:low:high, repeatable")
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args(argv)

values = values_from(args)
written = scaffold(args.out, values)
print(f"wrote {len(written)} files to {args.out}")
print(checklist(values))
return 0


if __name__ == "__main__":
sys.exit(main())
50 changes: 43 additions & 7 deletions machine/repos.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,49 @@
{
"notify": "Seungpyo1007",
"repos": [
{"name": "GetTechAPI/TechAPI", "branches": ["develop", "main"]},
{"name": "GetTechAPI/TechEngine", "branches": ["main"]},
{"name": "GetTechAPI/game-catalog", "branches": ["develop", "main"]},
{"name": "GetTechAPI/cpu-engineering-samples", "branches": ["develop", "main"]}
{
"name": "GetTechAPI/TechAPI",
"branches": [
"develop",
"main"
]
},
{
"name": "GetTechAPI/TechEngine",
"branches": [
"main"
]
},
{
"name": "GetTechAPI/game-catalog",
"branches": [
"develop",
"main"
]
},
{
"name": "GetTechAPI/cpu-engineering-samples",
"branches": [
"develop",
"main"
]
}
],
"endpoints": [
{"name": "TechAPI manifest", "url": "https://gettechapi.github.io/TechAPI/v1/index.json", "expect": "collections"},
{"name": "game-catalog summary", "url": "https://gettechapi.github.io/game-catalog/summary.json", "expect": "count"},
{"name": "cpu-engineering-samples summary", "url": "https://gettechapi.github.io/cpu-engineering-samples/summary.json", "expect": "count"}
{
"name": "TechAPI manifest",
"url": "https://gettechapi.github.io/TechAPI/v1/index.json",
"expect": "collections"
},
{
"name": "game-catalog summary",
"url": "https://gettechapi.github.io/game-catalog/summary.json",
"expect": "count"
},
{
"name": "cpu-engineering-samples summary",
"url": "https://gettechapi.github.io/cpu-engineering-samples/summary.json",
"expect": "count"
}
]
}
42 changes: 42 additions & 0 deletions machine/satellite_template/.github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: deploy-pages

on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # history.json replays every data commit
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Build summary + history
run: python site/build.py
- uses: actions/upload-pages-artifact@v3
with:
path: site

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
22 changes: 22 additions & 0 deletions machine/satellite_template/.github/workflows/validate-data.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: validate-data

on:
pull_request:
push:
branches: [develop, main]

jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 90 # 962k files; measure a real run before trimming
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Validate {{title}}
run: python -m app.validate
- name: Tests
run: |
pip install pytest
python -m pytest -q
8 changes: 8 additions & 0 deletions machine/satellite_template/DATA_LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Data license

JSON records under `data/` are licensed under
[Creative Commons Attribution-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-sa/4.0/).

Attribute **"Data from GetTechAPI / {{repo}}"** and share alike.

Validator, site, and workflow code remain MIT (see `LICENSE`).
Loading
Loading