mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 23:53:46 -06:00
chore: merge the autorun runner and jobs
This commit is contained in:
commit
07c34781e7
10 files changed
+2900
-2
No files matched your search
+1139
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user