chore: merge the autorun runner and jobs

This commit is contained in:
Abdessamad Derraz committed 2026-10-27 10:41:53 +01:00
commit 07c34781e7
10 files changed
+2900 -2

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
# Jobs scripts/autorun.py runs. A change here is a reviewed commit: the
# schedule, the gate and the paths a job may commit are all decided here.
#
# Commands run in the automation worktree (.cache/autorun/worktree) unless
# `worktree: false`, which runs them in the main checkout and forbids
# commits. `python` names the interpreter running autorun.py. A step is a
# command list, or a mapping with `run`, `ok_codes` and `stdout` (a file
# the command's output is written to). `twice` recomputes an artifact the
# gate wrote into `{out}` and compares both, timestamps aside. Each commit
# takes only the changed files its `paths` globs match, and a job that
# leaves any other change behind fails. `report` names a JSON file a step
# writes, kept in the job's status.
#
# Schedules are systemd OnCalendar expressions, written into user timers by
# `python scripts/autorun.py install-timers`.
defaults:
min_free_gb: 30
max_load_wait: 3600
timeout: 21600
jobs:
cleanup:
schedule: "*-*-* 05:30"
worktree: false
min_free_gb: 0
steps:
- [python, scripts/cleanup_disk.py, --apply, --hours, "72"]
disk-check:
schedule: hourly
worktree: false
lock: false
min_free_gb: 0
max_load_wait: 0
steps:
- [python, scripts/cleanup_disk.py, --check]
nightly:
schedule: "*-*-* 02:00"
network: true
min_free_gb: 120
steps:
- run: [python, scripts/check_freshness.py, --json]
ok_codes: [0, 1]
stdout: tmp/autorun/freshness.json
- [python, scripts/refresh_stale.py, --findings, tmp/autorun/freshness.json,
--only, "data,catalogs"]
- [python, -m, scripts.scraper.mame_hash_scraper]
- [python, -m, scripts.scraper.fbneo_hash_scraper]
gate:
- [python, scripts/pipeline.py, --offline]
twice:
- artifact: database.json
run: [python, scripts/generate_db.py, --force, --bios-dir, bios, --output, "{out}"]
- artifact: install
glob: "*.json"
run: [python, scripts/generate_pack.py, --all, --manifest, --offline,
--output-dir, "{out}"]
commits:
- subject: "chore: refresh mame and fbneo hashes"
body: >-
Written by mame_hash_scraper and fbneo_hash_scraper from the
upstream sources, behind a green offline pipeline.
paths: [emulators/mame*.yml, emulators/fbneo*.yml]
- subject: "chore: refresh romset recipes"
body: >-
Snapshots refreshed by refresh_stale from the MAME and FBNeo DATs
check_freshness reported stale, behind a green offline pipeline.
paths: [recipes/*.json]
- subject: "chore: regenerate collection records"
body: >-
Regenerated by the offline pipeline, database.json and the
manifests computed twice with the same result.
paths: [database.json, install/*.json, install/targets/*.json, README.md,
CONTRIBUTING.md, website/src/data/*.json]
scratch: [dist, tmp/autorun]
bios-integrity:
schedule: "Sun *-*-* 08:30"
worktree: false
max_load_wait: 14400
steps:
- run: [python, scripts/bios_integrity.py, --apply, --json]
stdout: tmp/autorun/bios-integrity.json
report: tmp/autorun/bios-integrity.json
assets:
schedule: "*-*-* 04:30"
network: true
steps:
- [python, scripts/sync_release_assets.py]
+1139
View File
File diff suppressed because it is too large. Load diff
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Rehash every file database.json names and repair a confirmed bit flip.
git status never shows a flipped bit: the index compares size and mtime, and
a flip changes neither. Only reading the bytes again finds it. Every file
the database names is hashed and compared with its recorded SHA-1. A
mismatch is read a second time: two reads that disagree are a host misread
and nothing on disk changes.
A confirmed mismatch is handled by where the file comes from:
- committed and unmodified for git: restored from its blob in HEAD, once
the blob's object id and its content SHA-1 both check out, then hashed
again. Only with --apply.
- modified for git (git status lists it): a working change some session
made, left alone and reported.
- gitignored (a large-files release asset) or untracked: flagged, never
repaired.
- a blob in HEAD whose content is not the recorded SHA-1: the database and
git disagree, flagged.
Run it on an idle host: a busy CPU here computes wrong hashes, which is the
fault this looks for. autorun.py's load guard holds the weekly job until the
host is idle.
Usage:
python scripts/bios_integrity.py
python scripts/bios_integrity.py --apply
python scripts/bios_integrity.py --apply --json
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
import tempfile
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from largefiles import load_registered_paths
MISREAD = "misread"
RESTORED = "restored"
FLAGGED = "flagged"
SKIPPED = "skipped"
@dataclass(frozen=True)
class Finding:
path: str
verdict: str # misread, restored, flagged, skipped
reason: str
expected: str
found: str = ""
@dataclass
class Report:
checked: int = 0
findings: list[Finding] = field(default_factory=list)
def counts(self) -> dict[str, int]:
counts: dict[str, int] = {}
for f in self.findings:
counts[f.verdict] = counts.get(f.verdict, 0) + 1
return counts
def as_dict(self) -> dict:
return {
"checked": self.checked,
"counts": self.counts(),
"findings": [f.__dict__ for f in self.findings],
}
@property
def clean(self) -> bool:
return all(f.verdict in (MISREAD, RESTORED) for f in self.findings)
def sha1_of(path: Path) -> str:
digest = hashlib.sha1()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def git(root: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(root), *args], capture_output=True, check=False)
def head_blob(root: Path, path: str) -> str:
"""The blob HEAD records for path, empty when HEAD has none."""
out = git(root, "ls-tree", "HEAD", "--", path).stdout.decode()
parts = out.split()
return parts[2] if len(parts) >= 3 and parts[1] == "blob" else ""
def modified_for_git(root: Path, path: str) -> bool:
return bool(git(root, "status", "--porcelain", "--", path).stdout.strip())
def restore_from_blob(root: Path, path: str, blob: str, expected: str) -> str:
"""Write the blob's bytes over path, after checking them. Empty on success."""
data = git(root, "cat-file", "blob", blob).stdout
object_id = hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest()
if object_id != blob:
return f"blob read back as {object_id[:12]}, not {blob[:12]}"
if hashlib.sha1(data).hexdigest() != expected:
return "HEAD holds other bytes than database.json records"
target = root / path
handle, scratch = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.")
try:
with os.fdopen(handle, "wb") as out:
out.write(data)
os.chmod(scratch, target.stat().st_mode & 0o777)
os.replace(scratch, target)
except OSError:
if os.path.exists(scratch):
os.unlink(scratch)
raise
return ""
def check(
root: Path,
db: dict,
apply: bool,
hasher: Callable[[Path], str] = sha1_of,
) -> Report:
"""Hash every database path, confirm each mismatch, repair what may be."""
ignored = set(load_registered_paths(root / ".gitignore"))
report = Report()
for key, entry in sorted(db.get("files", {}).items(), key=lambda kv: kv[1]["path"]):
path = entry["path"]
expected = str(entry.get("sha1") or key).lower()
local = root / path
if not local.is_file():
continue # a release asset not fetched yet, verify reports it
report.checked += 1
first = hasher(local)
if first == expected:
continue
second = hasher(local)
if second != first:
verdict = MISREAD if second == expected else FLAGGED
report.findings.append(Finding(
path, verdict, f"two reads disagree ({first[:12]}, {second[:12]})",
expected, second))
continue
if path in ignored:
report.findings.append(Finding(
path, FLAGGED, "gitignored large file, never repaired here", expected, first))
continue
blob = head_blob(root, path)
if not blob:
report.findings.append(Finding(path, FLAGGED, "not in HEAD", expected, first))
continue
if modified_for_git(root, path):
report.findings.append(Finding(
path, SKIPPED, "modified for git: a working change, left alone",
expected, first))
continue
if not apply:
report.findings.append(Finding(
path, FLAGGED, "bit flip, restorable from HEAD with --apply", expected, first))
continue
problem = restore_from_blob(root, path, blob, expected)
if problem:
report.findings.append(Finding(path, FLAGGED, problem, expected, first))
continue
after = hasher(local)
if after == expected:
report.findings.append(Finding(
path, RESTORED, f"restored from blob {blob[:12]}", expected, first))
else:
report.findings.append(Finding(
path, FLAGGED, f"restored but reads {after[:12]}", expected, after))
return report
def main() -> int:
parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__.split("\n\n")[0])
parser.add_argument("--apply", action="store_true",
help="restore confirmed flips of committed files from HEAD")
parser.add_argument("--db", default="database.json",
help="database file to read (default: %(default)s)")
parser.add_argument("--json", action="store_true", help="machine-readable report")
args = parser.parse_args()
from common import load_database
root = Path.cwd()
report = check(root, load_database(args.db), args.apply)
if args.json:
print(json.dumps(report.as_dict(), indent=2))
else:
for f in report.findings:
print(f"{f.verdict.upper():9} {f.path}: {f.reason}")
counts = ", ".join(f"{n} {v}" for v, n in sorted(report.counts().items()))
print(f"{report.checked} files hashed" + (f", {counts}" if counts else ", all match"))
return 0 if report.clean else 1
if __name__ == "__main__":
from common import run_cli
run_cli(main)
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""Free disk space that idle session worktrees and tmp/ hold, safely.
Dry run by default, --apply deletes.
Candidates are the linked worktrees placed inside the checkout, where
sessions create theirs. A worktree elsewhere on disk and the automation
worktree (.cache/autorun/worktree) are never candidates. One is removed only
when no process runs in or holds a file of it, it is older than --hours, and
nothing tracked is modified in it except generated artifacts (database.json,
install/*.json, exports/*.json, README.md, mkdocs.yml). Branches are always
kept, so committed work stays reachable. A worktree with real uncommitted
code is listed, never removed.
tmp/ entries older than --hours that no process uses are removed, except
tmp/acquire, git bundles (*.bundle, which are backups) and an entry that
holds a linked worktree, which the worktree rule judges.
The last line reports free space, and an ALERT line when it is under
--alert-gb. The exit code is 1 under that threshold, so a timer sees it.
Usage:
python scripts/cleanup_disk.py
python scripts/cleanup_disk.py --apply --hours 72
python scripts/cleanup_disk.py --check
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
import time
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
GENERATED = re.compile(
r"^(database\.json|install/.*\.json|exports/.*\.json|README\.md|mkdocs\.yml)$"
)
KEPT_TMP = ("acquire",)
AUTOMATION_WORKTREE = Path(".cache") / "autorun" / "worktree"
ALERT_GB = 100
@dataclass(frozen=True)
class Decision:
path: Path
kind: str # worktree, tmp
action: str # remove, keep
reason: str
def git(cwd: Path, *args: str, check: bool = True) -> str:
proc = subprocess.run(
["git", "-C", str(cwd), *args], capture_output=True, text=True, check=False,
)
if check and proc.returncode != 0:
raise RuntimeError(f"git {' '.join(args)}: {proc.stderr.strip()}")
return proc.stdout
def main_checkout(cwd: Path) -> Path:
"""The main checkout, also when run from inside a linked worktree."""
common = git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir").strip()
return Path(common).parent
def busy_paths() -> set[str]:
"""Every working directory and open file of every readable process."""
found: set[str] = set()
proc = Path("/proc")
if not proc.is_dir():
return found
for pid in proc.iterdir():
if not pid.name.isdigit():
continue
links = [pid / "cwd"]
try:
links += list((pid / "fd").iterdir())
except OSError:
pass
for link in links:
try:
found.add(os.readlink(link))
except OSError:
continue
return found
def inside(path: Path, root: Path) -> bool:
return str(path) == str(root) or str(path).startswith(f"{root}/")
def in_use(path: Path, busy: Iterable[str]) -> bool:
root = str(path)
prefix = f"{root}/"
return any(b == root or b.startswith(prefix) for b in busy)
def age_hours(path: Path, now: float) -> float:
return (now - path.stat().st_mtime) / 3600
def real_changes(worktree: Path) -> list[str]:
"""Modified tracked paths that are neither deletions nor generated files."""
out = git(worktree, "status", "--porcelain", "--untracked-files=no")
paths = []
for line in out.splitlines():
if len(line) < 4 or line[:2].strip() == "D":
continue
path = line[3:].split(" -> ")[-1].strip('"')
if not GENERATED.match(path):
paths.append(path)
return paths
def linked_worktrees(repo: Path) -> list[Path]:
"""Every linked worktree of the checkout, wherever it lives."""
out = git(repo, "worktree", "list", "--porcelain")
paths = [Path(line[len("worktree "):]) for line in out.splitlines()
if line.startswith("worktree ")]
return [p for p in paths if p != repo]
def session_worktrees(repo: Path) -> list[Path]:
"""Linked worktrees inside the checkout, the automation worktree aside."""
keep = repo / AUTOMATION_WORKTREE
return [p for p in linked_worktrees(repo) if inside(p, repo) and p != keep]
def judge_worktree(path: Path, busy: Iterable[str], hours: float, now: float) -> Decision:
if in_use(path, busy):
return Decision(path, "worktree", "keep", "in use")
age = age_hours(path, now)
if age <= hours:
return Decision(path, "worktree", "keep", f"{age:.0f}h recent")
real = real_changes(path)
if real:
return Decision(path, "worktree", "keep", f"uncommitted code: {len(real)} files")
return Decision(path, "worktree", "remove", f"{age:.0f}h old, clean")
def judge_tmp(
repo: Path, busy: Iterable[str], hours: float, now: float,
worktrees: Iterable[Path] = (),
) -> list[Decision]:
tmp = repo / "tmp"
if not tmp.is_dir():
return []
worktrees = list(worktrees)
decisions = []
for entry in sorted(tmp.iterdir()):
if entry.name in KEPT_TMP or entry.name.endswith(".bundle"):
action, reason = "keep", "backup"
elif any(inside(w, entry) for w in worktrees):
action, reason = "keep", "holds a worktree"
elif age_hours(entry, now) <= hours:
action, reason = "keep", "recent"
elif in_use(entry, busy):
action, reason = "keep", "in use"
else:
action, reason = "remove", "old"
decisions.append(Decision(entry, "tmp", action, reason))
return decisions
def plan(repo: Path, busy: Iterable[str], hours: float, now: float) -> list[Decision]:
busy = set(busy)
worktrees = [judge_worktree(w, busy, hours, now) for w in session_worktrees(repo)
if w.is_dir()]
return worktrees + judge_tmp(repo, busy, hours, now, linked_worktrees(repo))
def apply(repo: Path, decisions: Iterable[Decision]) -> list[str]:
"""Carry out the removals, and return the failures."""
failures = []
removed_worktree = False
for d in decisions:
if d.action != "remove":
continue
if d.kind == "worktree":
git(repo, "worktree", "unlock", str(d.path), check=False)
proc = subprocess.run(
["git", "-C", str(repo), "worktree", "remove", "--force", str(d.path)],
capture_output=True, text=True, check=False,
)
if proc.returncode != 0:
failures.append(f"{d.path}: {proc.stderr.strip()}")
removed_worktree = True
continue
try:
if d.path.is_dir() and not d.path.is_symlink():
shutil.rmtree(d.path)
else:
d.path.unlink()
except OSError as exc:
failures.append(f"{d.path}: {exc}")
if removed_worktree:
git(repo, "worktree", "prune", check=False)
return failures
def disk_line(path: Path, alert_gb: float) -> tuple[str, bool]:
usage = shutil.disk_usage(path)
free, total = usage.free / 1024 ** 3, usage.total / 1024 ** 3
low = free < alert_gb
line = f"disk: {free:.0f} GB free of {total:.0f} GB"
if low:
line += f"\nALERT: free space under {alert_gb:.0f} GB"
return line, low
def main() -> int:
parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__.split("\n\n")[0])
parser.add_argument("--apply", action="store_true", help="delete (default: dry run)")
parser.add_argument("--hours", type=float, default=72,
help="keep anything younger than this (default: %(default)s)")
parser.add_argument("--alert-gb", type=float, default=ALERT_GB,
help="alert under this much free space (default: %(default)s)")
parser.add_argument("--check", action="store_true",
help="report free space only, delete nothing")
args = parser.parse_args()
repo = main_checkout(Path.cwd())
if not args.check:
decisions = plan(repo, busy_paths(), args.hours, time.time())
verb = "removing" if args.apply else "would remove"
for d in decisions:
rel = d.path.relative_to(repo) if inside(d.path, repo) else d.path
label = verb if d.action == "remove" else f"keep ({d.reason})"
print(f"{label:28} {rel}")
failures = apply(repo, decisions) if args.apply else []
for failure in failures:
print(f"FAILED {failure}", file=sys.stderr)
if failures:
print(disk_line(repo, args.alert_gb)[0])
return 2
line, low = disk_line(repo, args.alert_gb)
print(line)
return 1 if low else 0
if __name__ == "__main__":
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import run_cli
run_cli(main)
+22 -1
View File
@@ -41,6 +41,7 @@ Usage:
python scripts/refresh_stale.py --dry-run
python scripts/refresh_stale.py --only platforms,targets
python scripts/refresh_stale.py --jobs 6
python scripts/refresh_stale.py --findings tmp/freshness.json --only data
"""
from __future__ import annotations
@@ -156,6 +157,17 @@ def _run_check_freshness(areas: tuple[str, ...], extra: list[str]) -> list[dict]
raise RuntimeError(f"check_freshness.py returned unreadable JSON: {exc}") from exc
def load_findings(path: Path, areas: tuple[str, ...]) -> list[dict]:
"""A saved check_freshness.py --json report, kept to the asked areas."""
try:
findings = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise RuntimeError(f"cannot read findings from {path}: {exc}") from exc
if not isinstance(findings, list):
raise RuntimeError(f"{path} is not a check_freshness.py --json report")
return [f for f in findings if f.get("area") in areas]
def _slug(subject: str) -> str:
"""A safe filename stem for a finding's subject."""
return re.sub(r"[^A-Za-z0-9_.-]+", "_", subject).strip("_") or "job"
@@ -557,6 +569,12 @@ def main() -> int:
action="store_true",
help="machine-readable summary (jobs, surfaced, ignored)",
)
parser.add_argument(
"--findings",
metavar="FILE",
help="read the check_freshness.py --json report from FILE instead of "
"running it (rows outside --only are left out)",
)
parser.add_argument(
"--freshness-arg",
action="append",
@@ -577,7 +595,10 @@ def main() -> int:
areas = all_areas
try:
findings = _run_check_freshness(areas, args.freshness_arg)
if args.findings:
findings = load_findings(Path(args.findings), areas)
else:
findings = _run_check_freshness(areas, args.freshness_arg)
except RuntimeError as exc:
print(exc, file=sys.stderr)
return 2
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""Upload the large-files assets the release lacks or serves out of date.
check_release_assets.py names every gitignored file the release does not
serve as the collection holds it: missing, another size, another sha256.
install.py refuses a download whose size differs from the manifest, so each
of these breaks every install of the file until it is uploaded again.
A file is uploaded only when its SHA-1, read twice from disk, gives the same
value both times and equals the SHA-1 database.json records for its path:
the bytes that go out are the bytes the manifests describe, and a read this
host got wrong is never published. The asset keeps the name
largefiles.asset_names() gives the path. After the uploads the release is
read again and every finding still open is reported.
Dry run by default: --apply uploads. The release description is not edited
here, check_release_assets.py --notes renders it.
Usage:
python scripts/sync_release_assets.py
python scripts/sync_release_assets.py --apply
"""
from __future__ import annotations
import argparse
import hashlib
import os
import shutil
import subprocess
import sys
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from check_release_assets import compare, expected_assets, expected_digests, fetch_release
from common import load_database
from largefiles import (
LARGE_FILES_RELEASE,
LARGE_FILES_REPO,
asset_names,
registered_paths,
)
STAGING = Path("tmp") / "sync_release_assets"
OUT_OF_DATE = ("missing", "size", "content")
@dataclass(frozen=True)
class Upload:
path: str
asset: str
kind: str
verdict: str # upload, refused
reason: str = ""
def sha1_of(path: Path) -> str:
digest = hashlib.sha1()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def database_sha1(db: dict) -> dict[str, str]:
return {
entry["path"]: str(entry.get("sha1") or key).lower()
for key, entry in db.get("files", {}).items()
if entry.get("path")
}
def plan_uploads(
root: Path,
db: dict,
gitignore_text: str,
findings: list,
hasher: Callable[[Path], str] = sha1_of,
) -> list[Upload]:
"""Each out-of-date asset, cleared for upload or refused with its reason."""
names = asset_names([*registered_paths(gitignore_text),
*expected_assets(db, gitignore_text)])
recorded = database_sha1(db)
plans = []
for kind, path, _size, _published in findings:
if kind not in OUT_OF_DATE:
continue
local = root / path
if not local.is_file():
plans.append(Upload(path, names[path], kind, "refused", "no local file"))
continue
first, second = hasher(local), hasher(local)
if first != second:
plans.append(Upload(path, names[path], kind, "refused",
f"two reads disagree ({first[:12]} != {second[:12]})"))
elif first != recorded.get(path):
plans.append(Upload(path, names[path], kind, "refused",
f"sha1 {first[:12]} is not the database's "
f"{str(recorded.get(path))[:12]}"))
else:
plans.append(Upload(path, names[path], kind, "upload"))
return plans
def gh_upload(staged: Path) -> int:
return subprocess.run(
["gh", "release", "upload", LARGE_FILES_RELEASE, str(staged), "--clobber",
"-R", LARGE_FILES_REPO],
check=False,
).returncode
def stage(root: Path, plan: Upload) -> Path:
"""The file under its asset name: gh names an asset after the file."""
staged = root / STAGING / plan.asset
staged.parent.mkdir(parents=True, exist_ok=True)
if staged.exists():
staged.unlink()
try:
os.link(root / plan.path, staged)
except OSError:
shutil.copy2(root / plan.path, staged)
return staged
def upload_all(
root: Path, plans: list[Upload], upload: Callable[[Path], int] = gh_upload
) -> list[str]:
"""Upload every cleared plan, twice at most, and return the failures."""
failed = []
for plan in plans:
if plan.verdict != "upload":
continue
staged = stage(root, plan)
try:
if upload(staged) != 0 and upload(staged) != 0:
failed.append(plan.path)
finally:
staged.unlink()
return failed
def open_findings(
db: dict, gitignore_text: str, fetch: Callable[[], tuple] = fetch_release
) -> list:
assets, _body, digests = fetch()
return compare(
expected_assets(db, gitignore_text),
assets,
registered=registered_paths(gitignore_text),
expected_digests=expected_digests(db, gitignore_text),
asset_digests=digests,
)
def main() -> int:
parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__.split("\n\n")[0])
parser.add_argument("--apply", action="store_true", help="upload (default: dry run)")
parser.add_argument("--db", default="database.json",
help="database file to read (default: %(default)s)")
args = parser.parse_args()
root = Path.cwd()
gitignore_text = (root / ".gitignore").read_text(encoding="utf-8")
db = load_database(args.db)
try:
findings = open_findings(db, gitignore_text)
except (subprocess.CalledProcessError, FileNotFoundError, KeyError) as exc:
print(f"ERROR: cannot list release assets: {exc}", file=sys.stderr)
return 2
plans = plan_uploads(root, db, gitignore_text, findings)
for plan in plans:
if plan.verdict == "upload":
verb = "uploading" if args.apply else "would upload"
print(f"{verb:13} {plan.kind:8} {plan.path} as {plan.asset}")
else:
print(f"{'refused':13} {plan.kind:8} {plan.path}: {plan.reason}")
if not plans:
print("release assets match the collection")
return 0
if not args.apply:
return 1
failed = upload_all(root, plans)
for path in failed:
print(f"FAILED upload of {path}, twice", file=sys.stderr)
remaining = [f for f in open_findings(db, gitignore_text) if f.kind in OUT_OF_DATE]
for kind, path, _size, _published in remaining:
print(f"still {kind}: {path}")
return 1 if remaining or failed else 0
if __name__ == "__main__":
from common import run_cli
run_cli(main)
+838
View File
@@ -0,0 +1,838 @@
"""autorun: the runner every unattended job goes through, and its first jobs.
Everything here is offline. Jobs are fake commands, git repositories are
built in tmp/tests with a bare repository standing in for origin, and gh is
a function returning canned CI runs.
"""
from __future__ import annotations
import contextlib
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import autorun as ar # noqa: E402
import bios_integrity as bib # noqa: E402
import cleanup_disk as cd # noqa: E402
import sync_release_assets as sra # noqa: E402
PY = "python"
_ENV_KEYS = ("GIT_CONFIG_GLOBAL", "GIT_CONFIG_NOSYSTEM")
_saved_env: dict[str, str | None] = {}
_config_dir: Path | None = None
def setUpModule() -> None:
global _config_dir
_config_dir = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp" / "tests"))
config = _config_dir / "gitconfig"
config.write_text(
"[user]\n\tname = Test\n\temail = test@example.invalid\n"
"[init]\n\tdefaultBranch = main\n"
"[commit]\n\tgpgsign = false\n",
encoding="utf-8",
)
for key in _ENV_KEYS:
_saved_env[key] = os.environ.get(key)
os.environ["GIT_CONFIG_GLOBAL"] = str(config)
os.environ["GIT_CONFIG_NOSYSTEM"] = "1"
def tearDownModule() -> None:
for key, value in _saved_env.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
if _config_dir is not None:
shutil.rmtree(_config_dir, ignore_errors=True)
def run_git(cwd: Path, *args: str) -> str:
return subprocess.run(
["git", "-C", str(cwd), *args], capture_output=True, text=True, check=True,
).stdout
class FakeHost(ar.Host):
def __init__(self, loads: list[float] | None = None, cpus: int = 4) -> None:
self.loads = list(loads or [0.0])
self.now = 0.0
self.slept: list[float] = []
super().__init__(
loadavg=self._load, sleep=self._sleep, clock=lambda: self.now,
cpu_count=cpus, poll=10.0,
)
def _load(self) -> tuple[float, float, float]:
value = self.loads.pop(0) if len(self.loads) > 1 else self.loads[0]
return (value, value, value)
def _sleep(self, seconds: float) -> None:
self.slept.append(seconds)
self.now += seconds
class Scratch(unittest.TestCase):
def setUp(self) -> None:
self.tmp = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp" / "tests"))
self.addCleanup(shutil.rmtree, self.tmp, True)
def context(self, jobs: dict, **kwargs) -> ar.Context:
repo = self.tmp / "repo"
repo.mkdir(exist_ok=True)
parsed = ar.parse_jobs({"jobs": jobs})
kwargs.setdefault("host", FakeHost())
return ar.Context(repo=repo, jobs=parsed, lock_path=self.tmp / "auto.lock", **kwargs)
def state(self, ctx: ar.Context) -> dict:
return json.loads(ctx.state_path.read_text(encoding="utf-8"))
class Registry(Scratch):
def test_the_committed_registry_loads(self):
jobs = ar.load_jobs()
self.assertIn("nightly", jobs)
self.assertTrue(jobs["nightly"].commits)
self.assertFalse(jobs["disk-check"].lock)
def test_an_unknown_key_is_refused(self):
with self.assertRaises(ValueError):
ar.parse_jobs({"jobs": {"x": {"steps": [], "schedul": "daily"}}})
def test_a_subject_the_hook_refuses_is_refused_at_load(self):
for subject in ("chore: fix the broken manifests",
"update things",
"chore: " + "a" * 60):
with self.subTest(subject=subject), self.assertRaises(ValueError):
ar.parse_jobs({"jobs": {"x": {"commits": [
{"subject": subject, "paths": ["a"]}]}}})
def test_a_job_outside_the_worktree_cannot_commit(self):
with self.assertRaises(ValueError):
ar.parse_jobs({"jobs": {"x": {"worktree": False, "commits": [
{"subject": "chore: add a", "paths": ["a"]}]}}})
def test_a_twice_entry_names_its_output(self):
with self.assertRaises(ValueError):
ar.parse_jobs({"jobs": {"x": {"twice": [
{"artifact": "a.json", "run": [PY, "gen.py"]}]}}})
class Lock(Scratch):
def test_a_second_holder_is_refused(self):
path = self.tmp / "auto.lock"
with ar.global_lock(path):
self.assertTrue(ar.lock_busy(path))
with self.assertRaises(ar.LockBusy), ar.global_lock(path):
pass
self.assertFalse(ar.lock_busy(path))
def test_a_job_waiting_on_the_lock_is_deferred_and_recorded(self):
ctx = self.context({"x": {"worktree": False, "min_free_gb": 0,
"steps": [[PY, "-c", "open('ran', 'w')"]]}})
with ar.global_lock(ctx.lock_path):
result, detail = ar.run_job(ctx, "x")
self.assertEqual(result, ar.DEFERRED)
self.assertIn("held", detail)
self.assertFalse((ctx.repo / "ran").exists())
self.assertEqual(self.state(ctx)["jobs"]["x"]["result"], ar.DEFERRED)
def test_a_job_that_takes_no_lock_runs_beside_a_holder(self):
ctx = self.context({"x": {"worktree": False, "lock": False, "min_free_gb": 0,
"steps": [[PY, "-c", "open('ran', 'w')"]]}})
with ar.global_lock(ctx.lock_path):
self.assertEqual(ar.run_job(ctx, "x")[0], ar.OK)
self.assertTrue((ctx.repo / "ran").exists())
class Guards(Scratch):
def test_the_load_guard_waits_then_lets_go(self):
host = FakeHost(loads=[3.0, 3.0, 0.5], cpus=4)
self.assertTrue(ar.wait_for_load(host, max_wait=600))
self.assertEqual(host.slept, [10.0, 10.0])
def test_the_load_guard_gives_up_at_its_deadline(self):
host = FakeHost(loads=[8.0], cpus=4)
self.assertFalse(ar.wait_for_load(host, max_wait=25))
self.assertEqual(sum(host.slept), 25)
def test_a_busy_host_defers_the_job(self):
ctx = self.context({"x": {"worktree": False, "min_free_gb": 0, "max_load_wait": 0,
"steps": [[PY, "-c", "open('ran', 'w')"]]}},
host=FakeHost(loads=[64.0]))
self.assertEqual(ar.run_job(ctx, "x")[0], ar.DEFERRED)
self.assertFalse((ctx.repo / "ran").exists())
def test_a_full_disk_refuses_the_job(self):
ctx = self.context({"x": {"worktree": False, "min_free_gb": 10 ** 9,
"steps": [[PY, "-c", "open('ran', 'w')"]]}})
result, detail = ar.run_job(ctx, "x")
self.assertEqual(result, ar.REFUSED)
self.assertIn("GB free", detail)
self.assertFalse((ctx.repo / "ran").exists())
def test_a_failure_counts_and_a_success_resets(self):
ctx = self.context({
"bad": {"worktree": False, "min_free_gb": 0, "steps": [[PY, "-c", "raise SystemExit(3)"]]},
})
ar.run_job(ctx, "bad")
ar.run_job(ctx, "bad")
record = self.state(ctx)["jobs"]["bad"]
self.assertEqual(record["result"], ar.FAILED)
self.assertEqual(record["consecutive_failures"], 2)
self.assertIsNone(record["last_success"])
class Retry(Scratch):
def _runs(self, *outcomes: ar.Outcome):
queue = list(outcomes)
calls = []
def run() -> ar.Outcome:
calls.append(1)
return queue.pop(0)
return run, calls
def test_a_segfault_is_retried_once_on_an_idle_host(self):
run, calls = self._runs(ar.Outcome(139), ar.Outcome(0))
waited = []
outcome = ar.run_with_retry(run, (0,), lambda: waited.append(1))
self.assertEqual((outcome.returncode, outcome.retried, len(calls), waited),
(0, True, 2, [1]))
def test_a_second_segfault_is_a_failure(self):
run, calls = self._runs(ar.Outcome(-11), ar.Outcome(-11), ar.Outcome(0))
outcome = ar.run_with_retry(run, (0,), lambda: None)
self.assertEqual((outcome.returncode, len(calls)), (-11, 2))
def test_a_host_misread_message_is_retried(self):
run, calls = self._runs(ar.Outcome(1, "SystemError: unknown opcode"), ar.Outcome(0))
self.assertEqual(ar.run_with_retry(run, (0,), lambda: None).returncode, 0)
self.assertEqual(len(calls), 2)
def test_an_ordinary_failure_is_not_retried(self):
run, calls = self._runs(ar.Outcome(1, "KeyError: 'x'"), ar.Outcome(0))
self.assertEqual(ar.run_with_retry(run, (0,), lambda: None).returncode, 1)
self.assertEqual(len(calls), 1)
def test_a_real_segfault_runs_again_and_the_job_passes(self):
crash = ("import os, signal, pathlib\n"
"p = pathlib.Path('crashed')\n"
"if not p.exists():\n"
" p.touch()\n"
" os.kill(os.getpid(), signal.SIGSEGV)\n")
ctx = self.context({"x": {"worktree": False, "min_free_gb": 0,
"steps": [[PY, "-c", crash]]}})
self.assertEqual(ar.run_job(ctx, "x")[0], ar.OK)
self.assertEqual(len(self.state(ctx)["jobs"]["x"]["retried"]), 1)
class DoubleComputation(Scratch):
GEN = ("import json, random, sys\n"
"json.dump({'generated_at': str(random.random()), 'value': %s},"
" open(sys.argv[1], 'w'))\n")
def _run(self, cwd: Path):
def run(argv):
return ar.Outcome(subprocess.run(argv, cwd=cwd, check=False).returncode)
return run
def test_timestamps_aside_two_equal_runs_agree(self):
script = self.tmp / "gen.py"
script.write_text(self.GEN % "1", encoding="utf-8")
diff = ar.twice([PY, str(script), "{out}"], self.tmp / "a.json",
self.tmp / "b.json", self._run(self.tmp))
self.assertEqual(diff, [])
def test_two_runs_that_disagree_are_named(self):
script = self.tmp / "gen.py"
script.write_text(self.GEN % "random.random()", encoding="utf-8")
diff = ar.twice([PY, str(script), "{out}"], self.tmp / "a.json",
self.tmp / "b.json", self._run(self.tmp))
self.assertEqual(diff, ["a.json"])
def test_directories_compare_file_by_file_within_the_glob(self):
a, b = self.tmp / "a", self.tmp / "b"
(a / "targets").mkdir(parents=True)
b.mkdir()
(a / "x.json").write_text('{"generated": "1", "n": 1}')
(b / "x.json").write_text('{"generated": "2", "n": 1}')
(a / "targets" / "t.json").write_text("{}")
self.assertEqual(ar.differences(a, b, "*.json"), [])
(b / "y.json").write_text("{}")
self.assertEqual(ar.differences(a, b, "*.json"), ["y.json"])
def test_a_job_whose_artifact_disagrees_fails_with_an_alert(self):
script = self.tmp / "repo" / "gen.py"
script.parent.mkdir(parents=True, exist_ok=True)
script.write_text(self.GEN % "random.random()", encoding="utf-8")
ctx = self.context({"x": {
"worktree": False, "min_free_gb": 0, "max_load_wait": 0,
"gate": [[PY, "gen.py", "db.json"]],
"twice": [{"artifact": "db.json", "run": [PY, "gen.py", "{out}"]}],
}})
result, detail = ar.run_job(ctx, "x")
self.assertEqual(result, ar.FAILED)
self.assertIn("two computations disagree", detail)
self.assertIn("host-integrity", self.state(ctx)["alerts"])
def test_a_job_whose_artifact_agrees_passes_and_clears_the_alert(self):
script = self.tmp / "repo" / "gen.py"
script.parent.mkdir(parents=True, exist_ok=True)
script.write_text(self.GEN % "1", encoding="utf-8")
ctx = self.context({"x": {
"worktree": False, "min_free_gb": 0,
"gate": [[PY, "gen.py", "db.json"]],
"twice": [{"artifact": "db.json", "run": [PY, "gen.py", "{out}"]}],
}})
ar.save_state(ctx.state_path, {"alerts": {"host-integrity": {"since": "x"}}})
self.assertEqual(ar.run_job(ctx, "x")[0], ar.OK)
self.assertNotIn("host-integrity", self.state(ctx).get("alerts", {}))
def ci_run(sha: str, conclusion: str | None, created: str, name: str = "Deploy Site") -> dict:
return {"workflowName": name, "headSha": sha, "createdAt": created,
"status": "completed" if conclusion else "in_progress",
"conclusion": conclusion or ""}
class CircuitBreaker(Scratch):
def _gh(self, by_commit: dict, on_main: list):
calls = []
def gh(args):
calls.append(args)
if "--commit" in args:
return by_commit.get(args[args.index("--commit") + 1], [])
return on_main
return gh, calls
def test_a_red_run_on_the_pushed_commit_opens_it(self):
state = {"push": {"sha": "a" * 40, "ci": "pending"}}
gh, _ = self._gh({"a" * 40: [ci_run("a" * 40, "success", "2026-10-27T02:10:00Z"),
ci_run("a" * 40, "failure", "2026-10-27T02:10:00Z",
"Validate")]}, [])
ar.refresh_breaker(state, gh, "now")
self.assertTrue(state["breaker"]["open"])
self.assertEqual(state["breaker"]["reason"], "Validate")
self.assertIn("ci", state["alerts"])
self.assertEqual(ar.may_push(state), "circuit breaker open")
def test_runs_still_going_keep_the_push_pending(self):
state = {"push": {"sha": "a" * 40, "ci": "pending"}}
gh, _ = self._gh({"a" * 40: [ci_run("a" * 40, None, "2026-10-27T02:10:00Z")]}, [])
ar.refresh_breaker(state, gh, "now")
self.assertFalse(state["breaker"]["open"])
self.assertIn("has not ended green", ar.may_push(state))
def test_a_later_green_run_on_main_closes_it(self):
state = {"push": {"sha": "a" * 40, "ci": "failure"},
"breaker": {"open": True, "sha": "a" * 40,
"failed_run_at": "2026-10-27T02:10:00Z"},
"alerts": {"ci": {"since": "x"}}}
pending = [ci_run("b" * 40, None, "2026-10-27T09:00:00Z")]
gh, _ = self._gh({}, pending + [ci_run("a" * 40, "failure", "2026-10-27T02:10:00Z")])
ar.refresh_breaker(state, gh, "now")
self.assertTrue(state["breaker"]["open"])
green = [ci_run("c" * 40, "success", "2026-10-27T10:00:00Z"),
ci_run("c" * 40, "skipped", "2026-10-27T10:00:01Z", "Validate")]
gh, _ = self._gh({}, green + pending)
ar.refresh_breaker(state, gh, "later")
self.assertFalse(state["breaker"]["open"])
self.assertNotIn("ci", state["alerts"])
def test_an_open_breaker_blocks_a_committing_job_before_any_git(self):
gh, _ = self._gh({"a" * 40: [ci_run("a" * 40, "failure", "t")]}, [])
ctx = self.context({"x": {"min_free_gb": 0, "commits": [
{"subject": "chore: add a", "paths": ["a"]}]}}, gh=gh)
ar.save_state(ctx.state_path, {"push": {"sha": "a" * 40, "ci": "pending"}})
result, _ = ar.run_job(ctx, "x")
self.assertEqual(result, ar.BLOCKED)
self.assertFalse(ctx.worktree.exists())
class Origin(Scratch):
"""A bare origin, a main checkout cloned from it, and a second clone
standing for another contributor."""
def setUp(self) -> None:
super().setUp()
self.origin = self.tmp / "origin.git"
run_git(self.tmp, "init", "--quiet", "--bare", "-b", "main", str(self.origin))
self.repo = self.tmp / "repo"
run_git(self.tmp, "clone", "--quiet", str(self.origin), str(self.repo))
(self.repo / ".gitignore").write_text(".cache/\ntmp/\n", encoding="utf-8")
(self.repo / "database.json").write_text("{}\n", encoding="utf-8")
(self.repo / "install").mkdir()
(self.repo / "install" / "a.json").write_text("{}\n", encoding="utf-8")
run_git(self.repo, "add", ".gitignore", "database.json", "install/a.json")
run_git(self.repo, "commit", "--quiet", "-m", "chore: start")
run_git(self.repo, "push", "--quiet", "origin", "main")
def other_commit(self, name: str) -> None:
other = self.tmp / "other"
if not other.exists():
run_git(self.tmp, "clone", "--quiet", str(self.origin), str(other))
run_git(other, "pull", "--quiet", "origin", "main")
(other / name).write_text(name, encoding="utf-8")
run_git(other, "add", name)
run_git(other, "commit", "--quiet", "-m", f"chore: add {name}")
run_git(other, "push", "--quiet", "origin", "main")
def worktree(self) -> Path:
wt = self.repo / ".cache" / "autorun" / "worktree"
ar.ensure_worktree(self.repo, wt)
return wt
class Commits(Origin):
def test_globs_stay_inside_one_segment(self):
self.assertTrue(ar.path_matches("install/*.json", "install/a.json"))
self.assertFalse(ar.path_matches("install/*.json", "install/targets/a.json"))
self.assertFalse(ar.path_matches("install/*.json", "x/install/a.json"))
def test_the_dry_run_names_only_matching_files_and_stages_nothing(self):
wt = self.worktree()
(wt / "database.json").write_text('{"n": 1}\n', encoding="utf-8")
(wt / "install" / "b.json").write_text("{}\n", encoding="utf-8")
(wt / "notes.txt").write_text("x", encoding="utf-8")
ctx = ar.Context(repo=self.repo, lock_path=self.tmp / "l", dry_run=True,
jobs=ar.parse_jobs({"jobs": {"n": {"commits": [{
"subject": "chore: regenerate collection records",
"paths": ["database.json", "install/*.json"]}]}}}))
with contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(ar.run_job(ctx, "n"), (ar.OK, "dry run"))
lines = ar.describe(ctx.jobs["n"], ctx)
would = sorted(l.split("would commit ")[1] for l in lines if "would commit" in l)
self.assertEqual(would, ["database.json", "install/b.json"])
self.assertEqual(run_git(wt, "diff", "--cached", "--name-only"), "")
self.assertFalse(ctx.state_path.exists())
def test_a_commit_takes_its_paths_and_nothing_staged_beside(self):
wt = self.worktree()
(wt / "database.json").write_text('{"n": 1}\n', encoding="utf-8")
(wt / "other.txt").write_text("x", encoding="utf-8")
run_git(wt, "add", "other.txt")
rule = ar.CommitRule("chore: regenerate collection records", ("database.json",))
files = ar.plan_commit(ar.changed_files(wt), rule.paths)
ar.commit_paths(wt, files, rule)
shown = run_git(wt, "show", "--name-only", "--format=%s", "HEAD").split()
self.assertEqual(shown[-1], "database.json")
self.assertNotIn("other.txt", shown)
self.assertEqual(run_git(wt, "diff", "--cached", "--name-only").split(), ["other.txt"])
class Worktree(Origin):
def test_it_is_created_on_its_own_branch_and_reused(self):
wt = self.worktree()
self.assertEqual(run_git(wt, "branch", "--show-current").strip(), ar.BRANCH)
self.assertEqual(run_git(self.repo, "branch", "--show-current").strip(), "main")
ar.ensure_worktree(self.repo, wt)
self.assertEqual(len(run_git(self.repo, "worktree", "list").splitlines()), 2)
def test_it_follows_origin_and_stops_on_leftovers(self):
wt = self.worktree()
self.other_commit("x.txt")
base = ar.sync_worktree(wt)
self.assertTrue((wt / "x.txt").exists())
self.assertEqual(base, run_git(wt, "rev-parse", "HEAD").strip())
(wt / "database.json").write_text("changed", encoding="utf-8")
with self.assertRaises(ar.AutorunError) as caught:
ar.sync_worktree(wt)
self.assertEqual(caught.exception.result, ar.BLOCKED)
self.assertEqual((wt / "database.json").read_text(), "changed")
def test_the_hard_links_share_bytes_with_the_main_checkout(self):
(self.repo / ".gitignore").write_text(".cache/\ntmp/\nbios/big.bin\n")
(self.repo / "bios").mkdir()
(self.repo / "bios" / "big.bin").write_bytes(b"x" * 10)
(self.repo / ".cache" / "large").mkdir(parents=True)
(self.repo / ".cache" / "large" / "a.zip").write_bytes(b"z")
wt = self.worktree()
seeded = ar.seed_worktree(self.repo, wt)
self.assertEqual(sorted(seeded), [".cache/large", "bios/big.bin"])
self.assertTrue(os.path.samefile(wt / "bios" / "big.bin", self.repo / "bios" / "big.bin"))
self.assertEqual(ar.seed_worktree(self.repo, wt), [])
class Push(Origin):
def _commit(self, wt: Path) -> None:
(wt / "database.json").write_text('{"n": 2}\n', encoding="utf-8")
ar.commit_paths(wt, ["database.json"],
ar.CommitRule("chore: regenerate collection records", ("database.json",)))
def test_a_batch_on_an_unmoved_origin_pushes_without_a_second_gate(self):
wt = self.worktree()
base = ar.sync_worktree(wt)
self._commit(wt)
gates = []
pushed = ar.push_batch(wt, base, lambda: gates.append(1) or True)
self.assertEqual(gates, [])
self.assertEqual(run_git(self.origin, "rev-parse", "main").strip(), pushed)
def test_a_moved_origin_rebases_and_gates_again(self):
wt = self.worktree()
base = ar.sync_worktree(wt)
self._commit(wt)
self.other_commit("y.txt")
gates = []
pushed = ar.push_batch(wt, base, lambda: gates.append(1) or True)
self.assertEqual(gates, [1])
log = run_git(self.origin, "log", "--format=%s", "main").splitlines()
self.assertEqual(log[:2], ["chore: regenerate collection records", "chore: add y.txt"])
self.assertEqual(run_git(self.origin, "rev-parse", "main").strip(), pushed)
def test_a_failed_second_gate_pushes_nothing(self):
wt = self.worktree()
base = ar.sync_worktree(wt)
self._commit(wt)
self.other_commit("y.txt")
before = run_git(self.origin, "rev-parse", "main")
with self.assertRaises(ar.AutorunError):
ar.push_batch(wt, base, lambda: False)
self.assertEqual(run_git(self.origin, "rev-parse", "main"), before)
def test_a_conflicting_batch_is_kept_and_nothing_pushed(self):
wt = self.worktree()
base = ar.sync_worktree(wt)
self._commit(wt)
other = self.tmp / "other"
run_git(self.tmp, "clone", "--quiet", str(self.origin), str(other))
(other / "database.json").write_text('{"n": 3}\n', encoding="utf-8")
run_git(other, "commit", "--quiet", "-am", "chore: other database")
run_git(other, "push", "--quiet", "origin", "main")
mine = run_git(wt, "rev-parse", "HEAD")
with self.assertRaises(ar.AutorunError) as caught:
ar.push_batch(wt, base, lambda: True)
self.assertEqual(caught.exception.result, ar.BLOCKED)
self.assertEqual(run_git(wt, "rev-parse", "HEAD"), mine)
self.assertEqual(ar.changed_files(wt), [])
class RunnerPush(Origin):
def _ctx(self, value: int, push: bool) -> ar.Context:
write = "open('database.json', 'w').write('{\"n\": %d}')" % value
jobs = {"n": {"min_free_gb": 0, "steps": [[PY, "-c", write]], "commits": [
{"subject": "chore: regenerate collection records", "paths": ["database.json"]}]}}
return ar.Context(repo=self.repo, jobs=ar.parse_jobs({"jobs": jobs}),
lock_path=self.tmp / "auto.lock", host=FakeHost(),
gh=lambda _args: [], push=push)
def test_without_push_the_commit_stays_local(self):
before = run_git(self.origin, "rev-parse", "main")
result, detail = ar.run_job(self._ctx(5, push=False), "n")
self.assertEqual((result, detail), (ar.OK, "1 commits"))
self.assertEqual(run_git(self.origin, "rev-parse", "main"), before)
self.assertEqual(ar.unpushed(self.repo / ".cache" / "autorun" / "worktree"), 1)
def test_a_push_waits_for_the_previous_push_to_end_green(self):
ctx = self._ctx(5, push=True)
result, detail = ar.run_job(ctx, "n")
self.assertEqual(result, ar.OK)
self.assertIn("pushed", detail)
pushed = run_git(self.origin, "rev-parse", "main").strip()
self.assertEqual(self.state(ctx)["push"]["sha"], pushed)
result, detail = ar.run_job(self._ctx(6, push=True), "n")
self.assertEqual(result, ar.OK)
self.assertIn("push held", detail)
self.assertEqual(run_git(self.origin, "rev-parse", "main").strip(), pushed)
class Units(Scratch):
def test_one_service_template_and_a_timer_per_scheduled_job(self):
jobs = ar.parse_jobs({"jobs": {
"nightly": {"schedule": "*-*-* 02:00"}, "manual": {}}})
units = ar.render_units(jobs, Path("/srv/retrobios"), "/usr/bin/python3")
self.assertEqual(sorted(units), ["retrobios-nightly.timer", "retrobios@.service"])
service = units["retrobios@.service"]
self.assertIn("ExecStart=/usr/bin/python3 /srv/retrobios/scripts/autorun.py run %i\n",
service)
self.assertIn("WorkingDirectory=/srv/retrobios\n", service)
self.assertIn("SuccessExitStatus=75\n", service)
timer = units["retrobios-nightly.timer"]
self.assertIn("OnCalendar=*-*-* 02:00\n", timer)
self.assertIn("Persistent=true\n", timer)
self.assertIn("Unit=retrobios@nightly.service\n", timer)
pushed = ar.render_units(jobs, Path("/r"), "py", push=True)["retrobios@.service"]
self.assertIn("run %i --push\n", pushed)
def test_units_are_written_and_enabled_only_when_asked(self):
units = ar.render_units(ar.load_jobs(), Path("/r"), "py")
calls = []
timers = ar.install_timers(self.tmp, units, False, lambda a: calls.append(a) or 0)
self.assertEqual(calls, [])
self.assertEqual(sorted(p.name for p in self.tmp.iterdir()), sorted(units))
ar.install_timers(self.tmp, units, True, lambda a: calls.append(a) or 0)
self.assertEqual(calls, [["daemon-reload"], ["enable", "--now", *timers]])
class Cleanup(Origin):
HOURS = 72
def _worktree(self, name: str, age_hours: float) -> Path:
path = self.repo / "sessions" / name
run_git(self.repo, "worktree", "add", "--quiet", "-b", f"wt-{name}", str(path))
stamp = time.time() - age_hours * 3600
os.utime(path, (stamp, stamp))
return path
def _decisions(self, busy=()) -> dict[str, cd.Decision]:
return {d.path.name: d for d in cd.plan(self.repo, busy, self.HOURS, time.time())}
def test_worktree_and_tmp_rules(self):
old_clean = self._worktree("old-clean", 100)
old_code = self._worktree("old-code", 100)
(old_code / "install" / "a.json").write_text("generated", encoding="utf-8")
(old_code / ".gitignore").write_text("edited", encoding="utf-8")
old_generated = self._worktree("old-generated", 100)
(old_generated / "database.json").write_text("generated", encoding="utf-8")
self._worktree("recent", 10)
busy = self._worktree("busy", 100)
tmp = self.repo / "tmp"
for name in ("acquire", "old", "fresh", "used"):
(tmp / name).mkdir(parents=True)
(tmp / "main.bundle").write_bytes(b"")
stamp = time.time() - 100 * 3600
for name in ("acquire", "old", "used", "main.bundle"):
os.utime(tmp / name, (stamp, stamp))
# The worktree mtimes moved when files were written into them.
for path in (old_clean, old_code, old_generated, busy):
os.utime(path, (stamp, stamp))
decisions = self._decisions(busy=[str(busy / "scripts"), str(tmp / "used" / "f")])
actions = {name: d.action for name, d in decisions.items()}
self.assertEqual(actions, {
"old-clean": "remove", "old-code": "keep", "old-generated": "remove",
"recent": "keep", "busy": "keep",
"acquire": "keep", "main.bundle": "keep", "old": "remove",
"fresh": "keep", "used": "keep",
})
self.assertIn("uncommitted code: 1 files", decisions["old-code"].reason)
failures = cd.apply(self.repo, decisions.values())
self.assertEqual(failures, [])
self.assertFalse(old_clean.exists())
self.assertFalse(old_generated.exists())
self.assertTrue(old_code.exists())
self.assertFalse((tmp / "old").exists())
self.assertTrue((tmp / "acquire").exists())
branches = run_git(self.repo, "branch", "--format=%(refname:short)").split()
self.assertIn("wt-old-clean", branches)
self.assertIn("wt-old-generated", branches)
def test_a_name_prefix_of_a_busy_path_is_not_in_use(self):
self.assertTrue(cd.in_use(Path("/r/tmp/a"), ["/r/tmp/a/x"]))
self.assertFalse(cd.in_use(Path("/r/tmp/a"), ["/r/tmp/a2/x"]))
def test_the_automation_worktree_is_never_a_candidate(self):
self.worktree()
self.assertEqual(cd.session_worktrees(self.repo), [])
def test_a_worktree_outside_the_checkout_is_never_a_candidate(self):
outside = self.tmp / "elsewhere"
run_git(self.repo, "worktree", "add", "--quiet", "-b", "away", str(outside))
self.assertEqual(cd.session_worktrees(self.repo), [])
def test_a_tmp_entry_holding_a_worktree_with_code_survives(self):
holder = self.repo / "tmp" / "holder"
holder.mkdir(parents=True)
work = holder / "wt"
run_git(self.repo, "worktree", "add", "--quiet", "-b", "held", str(work))
(work / ".gitignore").write_text("edited", encoding="utf-8")
stamp = time.time() - 100 * 3600
for path in (holder, work):
os.utime(path, (stamp, stamp))
decisions = self._decisions()
self.assertEqual((decisions["wt"].action, decisions["holder"].action), ("keep", "keep"))
self.assertEqual(decisions["holder"].reason, "holds a worktree")
cd.apply(self.repo, decisions.values())
self.assertEqual((work / ".gitignore").read_text(), "edited")
def test_low_space_raises_the_alert_line(self):
line, low = cd.disk_line(self.tmp, alert_gb=10 ** 9)
self.assertTrue(low)
self.assertIn("ALERT: free space under", line)
self.assertFalse(cd.disk_line(self.tmp, alert_gb=0)[1])
class AssetSync(Scratch):
GITIGNORE = "bios/A/big.bin\nbios/B/big.bin\nbios/C/solo.iso\n"
def setUp(self) -> None:
super().setUp()
self.root = self.tmp
for rel, data in (("bios/A/big.bin", b"a"), ("bios/C/solo.iso", b"c")):
(self.root / rel).parent.mkdir(parents=True, exist_ok=True)
(self.root / rel).write_bytes(data)
self.db = {"files": {
sra.sha1_of(self.root / "bios/A/big.bin"): {"path": "bios/A/big.bin", "size": 1},
"0" * 40: {"path": "bios/C/solo.iso", "size": 1},
}}
def test_only_a_file_whose_sha1_matches_twice_is_cleared(self):
findings = [("missing", "bios/A/big.bin", 1, None), ("size", "bios/C/solo.iso", 1, 5),
("notes", "release description", 0, None)]
plans = sra.plan_uploads(self.root, self.db, self.GITIGNORE, findings)
self.assertEqual([(p.path, p.asset, p.verdict) for p in plans], [
("bios/A/big.bin", "A--big.bin", "upload"),
("bios/C/solo.iso", "solo.iso", "refused"),
])
self.assertIn("not the database's", plans[1].reason)
def test_two_reads_that_disagree_refuse_the_upload(self):
reads = iter(["1" * 40, "2" * 40])
plans = sra.plan_uploads(self.root, self.db, self.GITIGNORE,
[("content", "bios/A/big.bin", 1, 1)],
hasher=lambda _p: next(reads))
self.assertEqual(plans[0].verdict, "refused")
self.assertIn("two reads disagree", plans[0].reason)
def test_an_upload_goes_under_its_asset_name_and_is_tried_twice(self):
plans = [sra.Upload("bios/A/big.bin", "A--big.bin", "missing", "upload"),
sra.Upload("bios/C/solo.iso", "solo.iso", "size", "refused", "x")]
seen = []
def upload(path: Path) -> int:
seen.append(path.name)
return 1
self.assertEqual(sra.upload_all(self.root, plans, upload), ["bios/A/big.bin"])
self.assertEqual(seen, ["A--big.bin", "A--big.bin"])
self.assertFalse((self.root / sra.STAGING / "A--big.bin").exists())
class BiosIntegrity(Scratch):
"""A flipped bit keeps the size and the mtime, so git status shows a
clean tree. Only the rehash against database.json finds it."""
OLD = time.time() - 10 * 86400
def setUp(self) -> None:
super().setUp()
self.repo = self.tmp / "repo"
self.repo.mkdir()
run_git(self.repo, "init", "--quiet", "-b", "main")
files = {
"bios/Aristocrat/aristmk6.zip": bytes(range(256)) * 4,
"bios/Sony/scph5501.bin": b"\x55" * 512,
"bios/Sony/edited.bin": b"\x01" * 64,
"bios/Big/large.iso": b"\x99" * 2048,
}
(self.repo / ".gitignore").write_text("bios/Big/large.iso\n", encoding="utf-8")
self.db = {"files": {}}
for rel, data in files.items():
path = self.repo / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
# An old mtime keeps git from re-reading the file as racily clean.
os.utime(path, (self.OLD, self.OLD))
sha1 = bib.sha1_of(path)
self.db["files"][sha1] = {"path": rel, "sha1": sha1}
run_git(self.repo, "add", ".gitignore", *[r for r in files if "Big" not in r])
run_git(self.repo, "commit", "--quiet", "-m", "chore: start")
def flip(self, rel: str, offset: int = 100) -> bytes:
"""Flip one bit in place, size and mtime unchanged."""
path = self.repo / rel
data = bytearray(path.read_bytes())
original = bytes(data)
data[offset] ^= 0x10
path.write_bytes(bytes(data))
os.utime(path, (self.OLD, self.OLD))
return original
def test_the_flip_is_invisible_to_git_and_restored(self):
rel = "bios/Aristocrat/aristmk6.zip"
original = self.flip(rel)
self.assertEqual(run_git(self.repo, "status", "--porcelain"), "")
report = bib.check(self.repo, self.db, apply=False)
self.assertEqual([(f.path, f.verdict) for f in report.findings], [(rel, bib.FLAGGED)])
self.assertNotEqual((self.repo / rel).read_bytes(), original)
report = bib.check(self.repo, self.db, apply=True)
self.assertEqual([(f.path, f.verdict) for f in report.findings], [(rel, bib.RESTORED)])
self.assertEqual((self.repo / rel).read_bytes(), original)
self.assertTrue(report.clean)
self.assertEqual(bib.check(self.repo, self.db, apply=True).findings, [])
def test_a_flip_in_a_gitignored_file_is_flagged_never_fixed(self):
rel = "bios/Big/large.iso"
self.flip(rel)
flipped = (self.repo / rel).read_bytes()
report = bib.check(self.repo, self.db, apply=True)
self.assertEqual([(f.path, f.verdict) for f in report.findings], [(rel, bib.FLAGGED)])
self.assertIn("gitignored", report.findings[0].reason)
self.assertEqual((self.repo / rel).read_bytes(), flipped)
self.assertFalse(report.clean)
def test_a_working_change_git_sees_is_left_to_its_session(self):
rel = "bios/Sony/edited.bin"
(self.repo / rel).write_bytes(b"\x02" * 80)
report = bib.check(self.repo, self.db, apply=True)
self.assertEqual([(f.path, f.verdict) for f in report.findings], [(rel, bib.SKIPPED)])
self.assertEqual((self.repo / rel).read_bytes(), b"\x02" * 80)
def test_a_mismatch_the_second_read_does_not_confirm_is_a_misread(self):
reads = {}
expected = {e["path"]: e["sha1"] for e in self.db["files"].values()}
def hasher(path: Path) -> str:
rel = path.relative_to(self.repo).as_posix()
reads[rel] = reads.get(rel, 0) + 1
if rel == "bios/Sony/scph5501.bin" and reads[rel] == 1:
return "f" * 40
return expected[rel]
report = bib.check(self.repo, self.db, apply=True, hasher=hasher)
self.assertEqual([(f.path, f.verdict) for f in report.findings],
[("bios/Sony/scph5501.bin", bib.MISREAD)])
self.assertTrue(report.clean)
def test_a_blob_that_is_not_the_database_content_is_not_written(self):
rel = "bios/Sony/scph5501.bin"
self.flip(rel)
db = json.loads(json.dumps(self.db))
for entry in db["files"].values():
if entry["path"] == rel:
entry["sha1"] = "0" * 40
flipped = (self.repo / rel).read_bytes()
report = bib.check(self.repo, db, apply=True)
self.assertEqual(report.findings[0].verdict, bib.FLAGGED)
self.assertIn("other bytes", report.findings[0].reason)
self.assertEqual((self.repo / rel).read_bytes(), flipped)
def test_the_runner_keeps_the_report_in_the_job_status(self):
rel = "bios/Aristocrat/aristmk6.zip"
self.flip(rel)
Path(self.db_path).write_text(json.dumps(self.db), encoding="utf-8")
script = str(REPO_ROOT / "scripts" / "bios_integrity.py")
ctx = ar.Context(
repo=self.repo, lock_path=self.tmp / "auto.lock", host=FakeHost(),
jobs=ar.parse_jobs({"jobs": {"bios-integrity": {
"worktree": False, "min_free_gb": 0,
"steps": [{"run": [PY, script, "--apply", "--json", "--db", self.db_path],
"stdout": "tmp/autorun/bios-integrity.json"}],
"report": "tmp/autorun/bios-integrity.json"}}}))
self.assertEqual(ar.run_job(ctx, "bios-integrity")[0], ar.OK)
report = self.state(ctx)["jobs"]["bios-integrity"]["report"]
self.assertEqual(report["counts"], {"restored": 1})
self.assertEqual(report["findings"][0]["path"], rel)
self.assertIn("1 restored", ar.render_status(ar.status(ctx)))
@property
def db_path(self) -> str:
return str(self.tmp / "database.json")
if __name__ == "__main__":
unittest.main()
+23
View File
@@ -206,5 +206,28 @@ class Running(unittest.TestCase):
self.assertFalse(marker.exists())
class SavedFindings(unittest.TestCase):
"""--findings reads the report a full check_freshness run saved, so the
nightly job checks every area once and refreshes only some."""
def test_rows_outside_the_asked_areas_are_left_out(self):
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp" / "tests") as directory:
path = Path(directory) / "freshness.json"
path.write_text(
'[{"area": "data", "subject": "dolphin-sys", "status": "STALE"},'
' {"area": "platforms", "subject": "batocera", "status": "STALE"}]',
encoding="utf-8",
)
findings = rs.load_findings(path, ("data", "catalogs"))
self.assertEqual([f["subject"] for f in findings], ["dolphin-sys"])
def test_a_report_that_is_not_a_list_is_refused(self):
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp" / "tests") as directory:
path = Path(directory) / "freshness.json"
path.write_text('{"area": "data"}', encoding="utf-8")
with self.assertRaises(RuntimeError):
rs.load_findings(path, ("data",))
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -58,6 +58,7 @@ Every module in `tests/`, by the area it guards.
| Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port` |
| Running cores | parsers on fixed inputs, a libretro core built from C in the test when a compiler exists | `test_core_probe` |
| Measurement records | synthetic record files, the committed `measurements/` against HEAD | `test_measurements` |
| Automation runner, disk cleanup, asset sync and bit-flip repair | fake jobs, git repositories with a bare origin and session worktrees, a committed file with one bit flipped, canned CI runs in place of `gh` | `test_autorun` |
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_search`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
+123 -1
View File
@@ -704,6 +704,124 @@ python scripts/refresh_stale.py
python scripts/refresh_stale.py --only platforms,targets --jobs 6
```
### autorun.py
Runs the unattended jobs declared in `automation/jobs.yml`, one at a time.
The registry holds each job's commands, its gate, the artifacts computed
twice, the paths each commit may carry and the schedule. Changing any of
them is a reviewed commit.
| Guard | Rule |
|-------|------|
| Lock | One host-wide `flock` on `~/.local/state/retrobios/auto.lock` (`RETROBIOS_AUTO_LOCK` moves it). A job that finds it held is `deferred`. |
| Load | Waits while the 1-minute load is above a quarter of the CPUs, up to `max_load_wait` seconds, then `deferred`. |
| Disk | Below the job's `min_free_gb` the job is `refused`. Under 100 GB free the `disk` alert is raised. |
| Retry | Exit 139 (or signal 11), `SystemError` or "unknown opcode" is run again once on an idle host. A second failure is a failure. |
| Double computation | Each `twice` entry recomputes an artifact the gate wrote and compares both, timestamps aside. A difference is recomputed once on an idle host, then the job fails with the `host-integrity` alert. |
Jobs that commit run in `.cache/autorun/worktree`, on the branch `autorun`,
never in the shared checkout and its index. The first run creates it and
hard-links `.cache/large/`, `data/` and the gitignored `bios/` files from the
main checkout. Before each job it is fast-forwarded, or rebased when it holds
unpushed commits, onto `origin/main`. A job that finds anything uncommitted
there stops as `blocked` and cleans nothing. Commits name their files
(`git commit -- <paths>`), and their subjects are checked against the
commit-msg hook when the registry loads. A job that leaves a change no commit
rule takes fails.
`--push` sends the batch: rebase on `origin/main` (a conflict keeps the batch
and pushes nothing), the gate again when `origin/main` moved, then a push that
never forces. It is off by default. A push waits while the previous automation
push has no green CI. A red run on a pushed automation commit, read with
`gh run list`, opens a circuit breaker: every committing job is `blocked`
until a green run on `main` follows.
State is `.cache/autorun/state.json`, logs are
`.cache/autorun/logs/<job>/<date>.log`, kept 30 days. A job's `report` key
names a JSON file one of its steps writes, and the runner keeps it in the
job's status. Exit codes: 0 for `ok`,
75 for `deferred`, 1 otherwise.
| Job | Schedule | What it does |
|-----|----------|--------------|
| `nightly` | 02:00 | `check_freshness.py --json` saved to a file, `refresh_stale.py --findings` on its `data` and `catalogs` rows, the MAME and FBNeo hash scrapers, then `pipeline.py --offline` as the gate. `database.json` and `install/*.json` are computed a second time and compared. Commits the hash profiles, the recipes and the regenerated records |
| `assets` | 04:30 | `sync_release_assets.py`, as a dry run |
| `bios-integrity` | Sunday 08:30 | `bios_integrity.py --apply --json` in the main checkout, once the host is idle (up to four hours of waiting). Its report is kept in the job's status |
| `cleanup` | 05:30 | `cleanup_disk.py --apply --hours 72` in the main checkout |
| `disk-check` | hourly | `cleanup_disk.py --check`, without the lock |
```bash
python scripts/autorun.py run nightly --dry-run # commands and the files each commit would take
python scripts/autorun.py run nightly
python scripts/autorun.py run nightly --push
python scripts/autorun.py status
python scripts/autorun.py status --json
python scripts/autorun.py install-timers # writes systemd user units, enables nothing
python scripts/autorun.py install-timers --enable
```
`install-timers` writes `retrobios@.service` and one `retrobios-<job>.timer`
per scheduled job into `--unit-dir` (default `~/.config/systemd/user`).
`--enable` also runs `systemctl --user daemon-reload` and enables the timers.
`--with-push` writes units whose jobs run with `--push`.
### cleanup_disk.py
Frees the space idle session worktrees and `tmp/` hold. The candidates are
the linked worktrees placed inside the checkout, where sessions create
theirs. One is removed only when no process uses it, it is older than
`--hours`, and nothing tracked changed in it but generated files
(`database.json`, `install/*.json`, `exports/*.json`, `README.md`,
`mkdocs.yml`). One holding real changes is listed, never removed. Branches
are always kept. A worktree elsewhere on disk and the automation worktree are
never candidates. `tmp/` entries older than `--hours` that no process uses
are removed, except `tmp/acquire`, `*.bundle` and an entry that holds a
linked worktree.
The last line gives free space, with an `ALERT` line under `--alert-gb`
(default 100), and the exit code is then 1. `--check` reports space only.
```bash
python scripts/cleanup_disk.py # dry run
python scripts/cleanup_disk.py --apply --hours 72
python scripts/cleanup_disk.py --check
```
### bios_integrity.py
A flipped bit keeps a file's size and mtime, so `git status` never shows it.
This rehashes every file `database.json` names and compares the SHA-1. A
mismatch is read a second time: two reads that disagree are a host misread
and change nothing. A confirmed mismatch on a committed file that git sees as
unmodified is restored from its blob in HEAD with `--apply`, once the blob's
object id and its content SHA-1 both check out, then hashed again. A file git
lists as modified is a working change and is left alone. A gitignored large
file, an untracked one, or a HEAD blob holding other bytes than the database
records is flagged, never repaired. The exit code is 1 while anything is
flagged. Run it on an idle host, since a busy CPU here computes wrong hashes.
```bash
python scripts/bios_integrity.py # report only
python scripts/bios_integrity.py --apply --json # restore committed flips
python scripts/bios_integrity.py --db database.json
```
### sync_release_assets.py
Uploads the `large-files` assets that `check_release_assets.py` reports
missing, at another size or with another sha256. A file goes out only when
its SHA-1, read twice, gives the same value both times and equals the SHA-1
`database.json` records for its path. The asset keeps the name
`largefiles.asset_names()` gives it, a failed upload is tried once more, and
the release is read again afterwards. The release description is never
edited. Dry run by default. The exit code is 1 while an asset stays out of
date or is refused, so the nightly `assets` job reports it.
```bash
python scripts/sync_release_assets.py
python scripts/sync_release_assets.py --apply
```
### core_probe.py
Runs a libretro core with no window, no sound and no input device, and
@@ -831,6 +949,10 @@ sorted and nothing reads the clock, so two builds give the same bytes.
| `check_buildbot_system.py` | Detect stale data directories by comparing with buildbot |
| `check_freshness.py` | One report on every upstream the repository transcribes (see above) |
| `refresh_stale.py` | Run the refresher behind every stale row of that report (see above) |
| `autorun.py` | Run the unattended jobs of `automation/jobs.yml` under one lock, in their own worktree (see above) |
| `cleanup_disk.py` | Remove idle session worktrees and old `tmp/` entries, alert under 100 GB free (see above) |
| `bios_integrity.py` | Rehash every file the database names, restore a confirmed bit flip of a committed file from HEAD (see above) |
| `sync_release_assets.py` | Upload the `large-files` assets the release serves out of date, after a double SHA-1 check (see above) |
| `migrate.py` | Migrate flat bios structure to Manufacturer/Console/ hierarchy |
Further options, by script:
@@ -851,7 +973,7 @@ Further options, by script:
| `mame_ref_audit.py` | `--write` points each stale ref at the line declaring its set, `--offline`, `--cache-dir` |
| `migrate.py` | `--dry-run`, `--include-branches` also reads BIOS files from every remote branch |
| `provenance_report.py` | `--provenance-dir`, `--write-derivable PATH` (with `--derive`, and the pipeline writes `derivable.json`) |
| `refresh_stale.py` | `--freshness-arg FLAG` passes a flag through to `check_freshness.py`, repeatable |
| `refresh_stale.py` | `--freshness-arg FLAG` passes a flag through to `check_freshness.py`, repeatable. `--findings FILE` reads a saved `check_freshness.py --json` report instead of running it, kept to the `--only` areas |
| `bundled_only.py` | `--strict` (exit 1 while a collected file only an emulator install provides remains, pipeline step 4d), `--json`, `--install-dir`. Lists every copy of such a file, the database keeping one path per content |
| `release_record.py` | `--install-dir` (manifests the archives are held against), `--platforms-dir` (registry the full pack names are rebuilt from: any other pack in the directory is refused), `--output` |
| `release_notes.py` | `--whats-new FILE` (required, appended as written), `--record`, `--platforms-dir`, `--output` |