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

+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)