Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-07-26 08:18:02

0001 #!/usr/bin/env python3
0002 # /// script
0003 # requires-python = ">=3.11"
0004 # dependencies = [
0005 #   "typer",
0006 #   "rich",
0007 #   "httpx",
0008 # ]
0009 # ///
0010 """
0011 Manage the physmon reference files.
0012 
0013 The reference histograms are not committed to the repository. Instead,
0014 `CI/physmon/reference.sha256` records the SHA256 of each reference file, and the
0015 contents live as content-addressed blobs in an OCI registry. This script
0016 resolves that manifest.
0017 
0018 Run it with `uv run --no-project CI/physmon/reference.py <command>`.
0019 """
0020 
0021 import concurrent.futures
0022 import csv
0023 import hashlib
0024 import json
0025 import os
0026 import re
0027 import shutil
0028 import subprocess
0029 import tempfile
0030 import threading
0031 from pathlib import Path
0032 from typing import Annotated, Any
0033 
0034 import httpx
0035 import typer
0036 from rich.console import Console
0037 from rich.progress import BarColumn, Progress, TextColumn, TimeRemainingColumn
0038 
0039 # soft_wrap keeps long paths on one line, which matters when this runs in a CI
0040 # log with a narrow assumed width
0041 console = Console(stderr=True, soft_wrap=True)
0042 app = typer.Typer(
0043     help=__doc__,
0044     no_args_is_help=True,
0045     add_completion=False,
0046     pretty_exceptions_show_locals=False,
0047 )
0048 
0049 REPO_ROOT = Path(__file__).resolve().parents[2]
0050 DEFAULT_MANIFEST = REPO_ROOT / "CI" / "physmon" / "reference.sha256"
0051 DEFAULT_REFDIR = REPO_ROOT / "CI" / "physmon" / "reference"
0052 DOCS = "docs/pages/contributing/physmon.md"
0053 DEFAULT_REGISTRY = os.environ.get(
0054     "ACTS_PHYSMON_REGISTRY", "ghcr.io/acts-project/physmon-references"
0055 )
0056 
0057 # The workflow whose runs carry the physmon outputs, and the artifact it
0058 # attaches them as
0059 BUILDS_WORKFLOW = "builds.yml"
0060 ARTIFACT = "physmon"
0061 
0062 # Blobs are stored raw, so the layer digest is the SHA256 of the file itself.
0063 # Being explicit keeps oras from tarring the input.
0064 MEDIA_TYPE = "application/octet-stream"
0065 
0066 CHUNK = 1024 * 1024
0067 
0068 RegistryOption = Annotated[
0069     str, typer.Option("--registry", help="OCI repository holding the blobs")
0070 ]
0071 ManifestOption = Annotated[
0072     Path, typer.Option("--manifest", help="Manifest listing the reference hashes")
0073 ]
0074 RefdirOption = Annotated[
0075     Path, typer.Option("--reference-dir", help="Where the reference files go")
0076 ]
0077 JobsOption = Annotated[int, typer.Option("--jobs", "-j", help="Concurrent downloads")]
0078 
0079 
0080 # --------------------------------------------------------------------------- #
0081 # manifest
0082 # --------------------------------------------------------------------------- #
0083 
0084 # The manifest is `sha256sum` format: "<hex>  <relative path>". It is plain text
0085 # so that reference updates show up as a reviewable diff, and so that
0086 # `sha256sum -c` works on it directly from the reference directory.
0087 
0088 
0089 def sha256_file(path: Path) -> str:
0090     h = hashlib.sha256()
0091     with path.open("rb") as f:
0092         for chunk in iter(lambda: f.read(CHUNK), b""):
0093             h.update(chunk)
0094     return h.hexdigest()
0095 
0096 
0097 def read_manifest(path: Path) -> dict[str, str]:
0098     if not path.exists():
0099         console.print(f"[red]No manifest at {path}[/red]")
0100         raise typer.Exit(1)
0101 
0102     entries: dict[str, str] = {}
0103     for lineno, line in enumerate(path.read_text().splitlines(), start=1):
0104         line = line.strip()
0105         if not line or line.startswith("#"):
0106             continue
0107         try:
0108             digest, relpath = line.split(None, 1)
0109         except ValueError:
0110             console.print(f"[red]{path}:{lineno}: malformed line: {line!r}[/red]")
0111             raise typer.Exit(1)
0112         # sha256sum marks binary mode with a '*' in front of the *file name*,
0113         # separated from the digest by a single space
0114         relpath = relpath.lstrip("*")
0115         if len(digest) != 64 or not all(c in "0123456789abcdef" for c in digest):
0116             console.print(f"[red]{path}:{lineno}: not a sha256: {digest!r}[/red]")
0117             raise typer.Exit(1)
0118         entries[relpath.strip()] = digest
0119     return entries
0120 
0121 
0122 def render_manifest(entries: dict[str, str]) -> str:
0123     return "".join(f"{entries[k]}  {k}\n" for k in sorted(entries))
0124 
0125 
0126 def write_manifest(path: Path, entries: dict[str, str]) -> None:
0127     path.write_text(render_manifest(entries))
0128 
0129 
0130 def manifest_from_dir(directory: Path) -> dict[str, str]:
0131     entries = {
0132         path.relative_to(directory).as_posix(): sha256_file(path)
0133         for path in sorted(directory.rglob("*"))
0134         if path.is_file()
0135     }
0136     if not entries:
0137         console.print(f"[red]No files found under {directory}[/red]")
0138         raise typer.Exit(1)
0139     return entries
0140 
0141 
0142 def manifest_tag(entries: dict[str, str]) -> str:
0143     """Tag derived from the manifest content, so re-publishing is idempotent."""
0144     return "m-" + hashlib.sha256(render_manifest(entries).encode()).hexdigest()[:16]
0145 
0146 
0147 # --------------------------------------------------------------------------- #
0148 # registry
0149 # --------------------------------------------------------------------------- #
0150 
0151 
0152 def split_registry(registry: str) -> tuple[str, str]:
0153     host, _, name = registry.partition("/")
0154     if not name:
0155         console.print(f"[red]Malformed registry reference: {registry!r}[/red]")
0156         raise typer.Exit(1)
0157     return host, name
0158 
0159 
0160 # Registry failures land on people who have never opened this file, so they get
0161 # an explanation of what to do rather than an httpx traceback.
0162 
0163 
0164 _reported: set[str] = set()
0165 _report_lock = threading.Lock()
0166 
0167 
0168 def abort(headline: str, *detail: str) -> typer.Exit:
0169     """Print an explanation and return the exception to raise.
0170 
0171     Downloads run concurrently, so a registry-wide problem hits every worker at
0172     once. Print each explanation only the first time it comes up.
0173     """
0174     with _report_lock:
0175         if headline in _reported:
0176             return typer.Exit(1)
0177         _reported.add(headline)
0178         console.print(f"[red]{headline}[/red]")
0179         for line in detail:
0180             console.print(line)
0181     return typer.Exit(1)
0182 
0183 
0184 def env_token() -> tuple[str | None, str | None]:
0185     """The credential to use, and the variable it came from."""
0186     for var in ("GITHUB_TOKEN", "ACTS_PHYSMON_TOKEN"):
0187         value = os.environ.get(var)
0188         if value:
0189             return value, var
0190     return None, None
0191 
0192 
0193 def auth_failure(host: str, name: str, status: int) -> typer.Exit:
0194     _, var = env_token()
0195     if var is None:
0196         cause = f"{host} refused an anonymous pull; the package should be public."
0197     else:
0198         cause = f"The token in {var} was rejected."
0199 
0200     return abort(
0201         f"Cannot pull from {host}/{name} (HTTP {status})",
0202         "",
0203         cause,
0204         "To pull with credentials, export a token with the 'read:packages' scope as",
0205         "GITHUB_TOKEN or ACTS_PHYSMON_TOKEN. 'gh auth token' does not carry that scope",
0206         "by default; add it with 'gh auth refresh --scopes read:packages'.",
0207         f"See '{DOCS}'.",
0208     )
0209 
0210 
0211 def missing_blob(host: str, name: str, digest: str) -> typer.Exit:
0212     return abort(
0213         f"{host}/{name} has no blob sha256:{digest}",
0214         "",
0215         "The manifest names a reference file that was never published. References have",
0216         "to be uploaded before the manifest naming them is committed; see 'How do I",
0217         f"update the reference files?' in '{DOCS}'.",
0218     )
0219 
0220 
0221 def network_failure(host: str, exc: httpx.RequestError) -> typer.Exit:
0222     return abort(
0223         f"Could not reach {host}: {exc}",
0224         "",
0225         "The reference histograms are not committed, so physmon cannot run without them.",
0226         "Set ACTS_PHYSMON_NO_FETCH=1 to use a reference directory you populated yourself.",
0227     )
0228 
0229 
0230 def registry_token(client: httpx.Client, host: str, name: str) -> str:
0231     """Get a pull token.
0232 
0233     Public packages allow anonymous pulls, but anonymous requests are rate
0234     limited aggressively enough that CI trips over it, so use GITHUB_TOKEN when
0235     one is available.
0236     """
0237     token, _ = env_token()
0238     auth = ("x-access-token", token) if token else None
0239     try:
0240         response = client.get(
0241             f"https://{host}/token",
0242             params={"service": host, "scope": f"repository:{name}:pull"},
0243             auth=auth,
0244             timeout=30,
0245         )
0246         response.raise_for_status()
0247     except httpx.HTTPStatusError as exc:
0248         status = exc.response.status_code
0249         if status in (401, 403):
0250             raise auth_failure(host, name, status) from None
0251         raise abort(f"{host} returned HTTP {status} for a pull token") from None
0252     except httpx.RequestError as exc:
0253         raise network_failure(host, exc) from None
0254 
0255     payload = response.json()
0256     # The spec calls the field "token"; "access_token" is the OAuth2 spelling
0257     # some registries answer with instead
0258     bearer = payload.get("token") or payload.get("access_token")
0259     if not bearer:
0260         raise abort(f"{host} did not return a token: {payload!r}")
0261     return bearer
0262 
0263 
0264 def cache_dir() -> Path:
0265     override = os.environ.get("ACTS_PHYSMON_CACHE")
0266     if override:
0267         return Path(override)
0268     base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
0269     return Path(base) / "acts" / "physmon-references"
0270 
0271 
0272 def download_blob(
0273     client: httpx.Client, host: str, name: str, digest: str, dest: Path
0274 ) -> None:
0275     url = f"https://{host}/v2/{name}/blobs/sha256:{digest}"
0276 
0277     # Two reference files can legitimately have identical content, in which case
0278     # several workers download the same digest at once. Stage each attempt under
0279     # its own name so they cannot overwrite each other.
0280     handle, staged = tempfile.mkstemp(dir=dest.parent, prefix=f"{digest}.")
0281     tmp = Path(staged)
0282     try:
0283         try:
0284             # fdopen owns the descriptor, so take it before anything can raise
0285             with os.fdopen(handle, "wb") as f:
0286                 with client.stream("GET", url, timeout=300) as response:
0287                     response.raise_for_status()
0288                     for chunk in response.iter_bytes(CHUNK):
0289                         f.write(chunk)
0290         except httpx.HTTPStatusError as exc:
0291             status = exc.response.status_code
0292             # A token that the token endpoint handed out happily can still be
0293             # refused here, when it does not carry the scope the blob needs
0294             if status in (401, 403):
0295                 raise auth_failure(host, name, status) from None
0296             if status == 404:
0297                 raise missing_blob(host, name, digest) from None
0298             raise abort(f"Fetching sha256:{digest} failed with HTTP {status}") from None
0299         except httpx.RequestError as exc:
0300             raise network_failure(host, exc) from None
0301 
0302         got = sha256_file(tmp)
0303         if got != digest:
0304             console.print(f"[red]Digest mismatch: expected {digest}, got {got}[/red]")
0305             raise typer.Exit(1)
0306         tmp.replace(dest)
0307     finally:
0308         tmp.unlink(missing_ok=True)
0309 
0310 
0311 def materialize(
0312     entries: dict[str, str], output_dir: Path, registry: str, jobs: int
0313 ) -> int:
0314     """Populate output_dir with the files named by the manifest."""
0315     output_dir.mkdir(parents=True, exist_ok=True)
0316     cache = cache_dir()
0317     cache.mkdir(parents=True, exist_ok=True)
0318 
0319     def up_to_date(relpath: str, digest: str) -> bool:
0320         target = output_dir / relpath
0321         return target.exists() and sha256_file(target) == digest
0322 
0323     outstanding = {k: v for k, v in entries.items() if not up_to_date(k, v)}
0324     if not outstanding:
0325         console.print(f"{len(entries)} reference file(s) already present")
0326         return 0
0327 
0328     # Only talk to the registry if something is actually missing from the cache
0329     needs_network = any(not (cache / d).exists() for d in outstanding.values())
0330     host, name = split_registry(registry)
0331 
0332     with httpx.Client(follow_redirects=True) as client:
0333         if needs_network:
0334             # httpx drops the Authorization header when a redirect crosses
0335             # origins, which is what blob reads do when they hand off to a CDN
0336             token = registry_token(client, host, name)
0337             client.headers["Authorization"] = f"Bearer {token}"
0338 
0339         def one(item: tuple[str, str]) -> None:
0340             relpath, digest = item
0341             blob = cache / digest
0342             if not (blob.exists() and sha256_file(blob) == digest):
0343                 download_blob(client, host, name, digest, blob)
0344             target = output_dir / relpath
0345             target.parent.mkdir(parents=True, exist_ok=True)
0346             shutil.copyfile(blob, target)
0347 
0348         with Progress(
0349             TextColumn("[progress.description]{task.description}"),
0350             BarColumn(),
0351             TextColumn("{task.completed}/{task.total}"),
0352             TimeRemainingColumn(),
0353             console=console,
0354             transient=True,
0355         ) as progress:
0356             task = progress.add_task("Fetching references", total=len(outstanding))
0357             with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool:
0358                 for _ in pool.map(one, outstanding.items()):
0359                     progress.advance(task)
0360 
0361     console.print(
0362         f"{len(outstanding)} reference file(s) fetched, "
0363         f"{len(entries) - len(outstanding)} already present"
0364     )
0365     return len(outstanding)
0366 
0367 
0368 def require(tool: str) -> None:
0369     if shutil.which(tool) is None:
0370         console.print(
0371             f"[red]'{tool}' is required for this command but is not on PATH[/red]"
0372         )
0373         raise typer.Exit(1)
0374 
0375 
0376 def oras_push(registry: str, directory: Path, entries: dict[str, str]) -> str:
0377     """Push the full reference set as one tagged artifact.
0378 
0379     Pushing every file (rather than only the new ones) keeps every blob
0380     referenced by a manifest, so the registry never considers them garbage. The
0381     registry skips uploading blobs it already has, so unchanged files cost
0382     nothing beyond an existence check.
0383     """
0384     require("oras")
0385     ref = f"{registry}:{manifest_tag(entries)}"
0386 
0387     args = ["oras", "push", "--format", "json", ref]
0388     args += [f"{relpath}:{MEDIA_TYPE}" for relpath in sorted(entries)]
0389 
0390     console.print(f"Pushing {len(entries)} file(s) to [bold]{ref}[/bold]")
0391     result = subprocess.run(
0392         args, cwd=directory, capture_output=True, text=True, check=False
0393     )
0394     if result.returncode != 0:
0395         console.print(result.stdout)
0396         console.print(f"[red]{result.stderr}[/red]")
0397         console.print(f"[red]oras push failed (exit {result.returncode})[/red]")
0398         raise typer.Exit(1)
0399 
0400     # Guard against oras ever packing the files instead of storing them raw: the
0401     # layer digests must be the file hashes, or fetch-by-hash cannot work.
0402     try:
0403         layers = {
0404             layer["digest"].removeprefix("sha256:")
0405             for layer in json.loads(result.stdout).get("layers", [])
0406         }
0407     except (json.JSONDecodeError, KeyError, TypeError):
0408         console.print(
0409             "[yellow]Could not parse oras output, skipping digest check[/yellow]"
0410         )
0411         return ref
0412 
0413     missing = set(entries.values()) - layers
0414     if layers and missing:
0415         console.print(
0416             "[red]oras did not store the files as raw blobs; "
0417             f"{len(missing)} expected digest(s) absent from the manifest[/red]"
0418         )
0419         raise typer.Exit(1)
0420     return ref
0421 
0422 
0423 # --------------------------------------------------------------------------- #
0424 # github
0425 # --------------------------------------------------------------------------- #
0426 
0427 # What identifies a physmon run to a person is the pull request, not the run id
0428 # buried in its checks tab, so accept either and work out the rest here rather
0429 # than in the workflow: the same resolution then applies when this is run by
0430 # hand.
0431 
0432 PR_URL = re.compile(r"github\.com/([^/\s]+/[^/\s]+)/pull/(\d+)")
0433 RUN_URL = re.compile(r"github\.com/([^/\s]+/[^/\s]+)/actions/runs/(\d+)")
0434 
0435 
0436 def gh(*args: str) -> subprocess.CompletedProcess[str]:
0437     return subprocess.run(["gh", *args], capture_output=True, text=True, check=False)
0438 
0439 
0440 def gh_json(args: list[str], what: str) -> Any:
0441     result = gh(*args)
0442     if result.returncode != 0:
0443         raise abort(f"Could not {what}", "", result.stderr.strip())
0444     try:
0445         return json.loads(result.stdout)
0446     except json.JSONDecodeError:
0447         raise abort(
0448             f"Unexpected response while trying to {what}", "", result.stdout[:400]
0449         )
0450 
0451 
0452 def classify(target: str, repo: str) -> tuple[str, int, str]:
0453     """Work out whether a target names a pull request or a workflow run.
0454 
0455     Returns the kind, the number, and the repository it belongs to, which a URL
0456     can name explicitly.
0457     """
0458     target = target.strip()
0459     if match := RUN_URL.search(target):
0460         return "run", int(match.group(2)), match.group(1)
0461     if match := PR_URL.search(target):
0462         return "pr", int(match.group(2)), match.group(1)
0463 
0464     if match := re.fullmatch(r"#?(\d+)", target):
0465         number = int(match.group(1))
0466         # Run ids are much larger than pull request numbers, but ask GitHub
0467         # rather than lean on that: being wrong here publishes the wrong physics
0468         if gh("api", f"repos/{repo}/pulls/{number}", "--silent").returncode == 0:
0469             return "pr", number, repo
0470         if gh("api", f"repos/{repo}/actions/runs/{number}", "--silent").returncode == 0:
0471             return "run", number, repo
0472         raise abort(
0473             f"{number} is neither a pull request nor a workflow run in {repo}",
0474             "",
0475             "Pass --repo if it belongs to a different repository.",
0476         )
0477 
0478     raise abort(
0479         f"Cannot make sense of {target!r}",
0480         "",
0481         "Expected a pull request number, a pull request URL, a Builds run id, or",
0482         "a Builds run URL.",
0483     )
0484 
0485 
0486 def artifact_of(repo: str, run_id: int) -> dict[str, Any] | None:
0487     """The run's physmon artifact, if it has one that has not expired yet."""
0488     payload = gh_json(
0489         ["api", f"repos/{repo}/actions/runs/{run_id}/artifacts?per_page=100"],
0490         f"list the artifacts of run {run_id}",
0491     )
0492     for artifact in payload.get("artifacts", []):
0493         if artifact["name"] == ARTIFACT and not artifact["expired"]:
0494             return artifact
0495     return None
0496 
0497 
0498 def run_for_pr(repo: str, pr: int) -> int:
0499     """The newest Builds run for the pull request head that still has outputs."""
0500     info = gh_json(
0501         ["api", f"repos/{repo}/pulls/{pr}"], f"look up pull request #{pr} in {repo}"
0502     )
0503     head = info["head"]["sha"]
0504     console.print(
0505         f"Pull request #{pr} ({info['title']}): head {head[:10]} on {info['head']['label']}"
0506     )
0507 
0508     runs = gh_json(
0509         [
0510             "api",
0511             f"repos/{repo}/actions/workflows/{BUILDS_WORKFLOW}/runs"
0512             f"?head_sha={head}&per_page=50",
0513         ],
0514         f"list the {BUILDS_WORKFLOW} runs for {head[:10]}",
0515     )["workflow_runs"]
0516 
0517     # A branch in the repository itself produces both a push and a pull_request
0518     # run for the same commit, and a re-run adds more; any of them will do, so
0519     # take the newest that still has the artifact
0520     for run in sorted(runs, key=lambda r: r["created_at"], reverse=True):
0521         if artifact_of(repo, run["id"]):
0522             return run["id"]
0523 
0524     if not runs:
0525         raise abort(
0526             f"No {BUILDS_WORKFLOW} run for the head commit of #{pr} ({head[:10]})",
0527             "",
0528             "The run may not have started yet. Note that the references come from",
0529             "the head commit: if the branch was pushed to after the physmon run,",
0530             "that run no longer describes the pull request.",
0531         )
0532     raise abort(
0533         f"No run for #{pr} ({head[:10]}) has a '{ARTIFACT}' artifact",
0534         "",
0535         *(
0536             f"  run {run['id']}: {run['status']}"
0537             + (f", {run['conclusion']}" if run["conclusion"] else "")
0538             for run in sorted(runs, key=lambda r: r["created_at"], reverse=True)
0539         ),
0540         "",
0541         "The physmon job may still be running or have failed before uploading, or",
0542         "the artifacts may have expired: GitHub keeps them only for a retention",
0543         "period, after which the physmon job has to be re-run on the pull request.",
0544     )
0545 
0546 
0547 def resolve_run(target: str, repo: str) -> tuple[int, str]:
0548     """Turn what the user passed into a run id to publish."""
0549     require("gh")
0550     kind, number, repo = classify(target, repo)
0551 
0552     if kind == "pr":
0553         run_id = run_for_pr(repo, number)
0554     else:
0555         run_id = number
0556         if artifact_of(repo, run_id) is None:
0557             raise abort(
0558                 f"Run {run_id} has no '{ARTIFACT}' artifact",
0559                 "",
0560                 "Either the physmon job did not upload one, or the artifact has",
0561                 "expired and the run has to be repeated.",
0562             )
0563 
0564     console.print(
0565         f"Publishing from run {run_id}: https://github.com/{repo}/actions/runs/{run_id}"
0566     )
0567     return run_id, repo
0568 
0569 
0570 # --------------------------------------------------------------------------- #
0571 # commands
0572 # --------------------------------------------------------------------------- #
0573 
0574 
0575 @app.command()
0576 def pull(
0577     manifest: ManifestOption = DEFAULT_MANIFEST,
0578     reference_dir: RefdirOption = DEFAULT_REFDIR,
0579     registry: RegistryOption = DEFAULT_REGISTRY,
0580     jobs: JobsOption = 8,
0581 ) -> None:
0582     """Populate the reference directory from the manifest."""
0583     materialize(read_manifest(manifest), reference_dir, registry, jobs)
0584 
0585 
0586 @app.command()
0587 def verify(
0588     manifest: ManifestOption = DEFAULT_MANIFEST,
0589     reference_dir: RefdirOption = DEFAULT_REFDIR,
0590 ) -> None:
0591     """Check an existing reference directory against the manifest."""
0592     entries = read_manifest(manifest)
0593 
0594     problems = []
0595     for relpath, digest in sorted(entries.items()):
0596         path = reference_dir / relpath
0597         if not path.exists():
0598             problems.append(f"missing: {relpath}")
0599         elif sha256_file(path) != digest:
0600             problems.append(f"modified: {relpath}")
0601 
0602     if reference_dir.exists():
0603         present = {
0604             p.relative_to(reference_dir).as_posix()
0605             for p in reference_dir.rglob("*")
0606             if p.is_file()
0607         }
0608         problems += [f"untracked: {p}" for p in sorted(present - set(entries))]
0609 
0610     for line in problems:
0611         console.print(f"[red]{line}[/red]")
0612     if problems:
0613         console.print(f"[red]{len(problems)} problem(s) against {manifest}[/red]")
0614         raise typer.Exit(1)
0615     console.print(f"[green]{len(entries)} reference file(s) match {manifest}[/green]")
0616 
0617 
0618 def read_results(path: Path) -> list[dict[str, str]]:
0619     """Read histcmp_results.csv, tolerating the pre-manifest 3-column format."""
0620     rows = []
0621     with path.open() as f:
0622         for row in csv.reader(f):
0623             if not row:
0624                 continue
0625             row = row + [""] * (5 - len(row))
0626             title, html_path, ec, ref_path, monitored_path = row[:5]
0627             rows.append(
0628                 {
0629                     "title": title,
0630                     "html_path": html_path,
0631                     "ec": ec,
0632                     "ref_path": ref_path,
0633                     "monitored_path": monitored_path,
0634                 }
0635             )
0636     return rows
0637 
0638 
0639 @app.command()
0640 def candidate(
0641     results: Annotated[
0642         Path, typer.Option("--results", help="histcmp_results.csv from the run")
0643     ],
0644     data_dir: Annotated[
0645         Path, typer.Option("--data-dir", help="Directory holding the run's outputs")
0646     ],
0647     output: Annotated[
0648         Path, typer.Option("--output", help="Where to write the manifest")
0649     ],
0650     manifest: ManifestOption = DEFAULT_MANIFEST,
0651     replace_all: Annotated[
0652         bool,
0653         typer.Option("--all", help="Replace every entry, not just failing comparisons"),
0654     ] = False,
0655 ) -> None:
0656     """Build the manifest that would apply if a run's failures were accepted.
0657 
0658     The changed set comes from the histcmp exit codes, not from comparing
0659     hashes: ROOT files embed a creation timestamp, so every physmon run produces
0660     a different hash for every file even when the physics is unchanged.
0661     """
0662     entries = read_manifest(manifest)
0663 
0664     updated, skipped = [], []
0665     for row in read_results(results):
0666         if not row["ref_path"]:
0667             continue  # comparison between two monitored outputs, no reference
0668         if row["ec"] == "0" and not replace_all:
0669             continue
0670         source = data_dir / (row["monitored_path"] or row["ref_path"])
0671         if not source.exists():
0672             skipped.append(f"{row['ref_path']} (no output at {source})")
0673             continue
0674         entries[row["ref_path"]] = sha256_file(source)
0675         updated.append(row["ref_path"])
0676 
0677     write_manifest(output, entries)
0678 
0679     for relpath in sorted(updated):
0680         console.print(f"candidate update: {relpath}")
0681     for line in sorted(skipped):
0682         console.print(f"[yellow]candidate skipped: {line}[/yellow]")
0683     console.print(f"Wrote {output} ({len(updated)} of {len(entries)} entries updated)")
0684 
0685 
0686 @app.command("import")
0687 def import_references(
0688     reference_dir: RefdirOption = DEFAULT_REFDIR,
0689     manifest: ManifestOption = DEFAULT_MANIFEST,
0690     registry: RegistryOption = DEFAULT_REGISTRY,
0691     dry_run: Annotated[
0692         bool,
0693         typer.Option("--dry-run", help="Hash and write the manifest, upload nothing"),
0694     ] = False,
0695 ) -> None:
0696     """Seed the registry from an existing reference directory.
0697 
0698     This is the one-off migration step: it uploads the reference files that used
0699     to be committed and writes the manifest that replaces them.
0700     """
0701     entries = manifest_from_dir(reference_dir)
0702     console.print(f"{len(entries)} file(s) under {reference_dir}")
0703 
0704     if dry_run:
0705         console.print("[yellow]Dry run: not contacting the registry[/yellow]")
0706     else:
0707         console.print(
0708             f"Published [bold]{oras_push(registry, reference_dir, entries)}[/bold]"
0709         )
0710 
0711     write_manifest(manifest, entries)
0712     console.print(f"[green]Wrote {manifest}[/green]")
0713 
0714 
0715 @app.command()
0716 def update(
0717     target: Annotated[
0718         str,
0719         typer.Argument(
0720             help="Pull request number or URL, or the id or URL of a Builds run"
0721         ),
0722     ],
0723     repo: Annotated[
0724         str, typer.Option("--repo", help="Repository the run belongs to")
0725     ] = os.environ.get("GITHUB_REPOSITORY", "acts-project/acts"),
0726     output: Annotated[
0727         Path | None,
0728         typer.Option("--output", help="Write the manifest here instead of stdout"),
0729     ] = None,
0730     registry: RegistryOption = DEFAULT_REGISTRY,
0731     jobs: JobsOption = 8,
0732     dry_run: Annotated[
0733         bool, typer.Option("--dry-run", help="Report what would happen, upload nothing")
0734     ] = False,
0735 ) -> None:
0736     """Publish the references a physmon run implies, and print the new manifest."""
0737     run_id, repo = resolve_run(target, repo)
0738 
0739     with tempfile.TemporaryDirectory() as tmp:
0740         artifact = Path(tmp) / "artifact"
0741         artifact.mkdir()
0742         console.print(f"Downloading the {ARTIFACT} artifact from run {run_id}")
0743         result = subprocess.run(
0744             [
0745                 "gh",
0746                 "run",
0747                 "download",
0748                 str(run_id),
0749                 "--repo",
0750                 repo,
0751                 "--name",
0752                 ARTIFACT,
0753                 "--dir",
0754                 str(artifact),
0755             ],
0756             check=False,
0757         )
0758         if result.returncode != 0:
0759             raise abort(
0760                 f"Could not download the '{ARTIFACT}' artifact from run {run_id}"
0761             )
0762 
0763         candidate_manifest = artifact / "reference-candidate.sha256"
0764         if not candidate_manifest.exists():
0765             console.print(
0766                 f"[red]{candidate_manifest.name} is not in the artifact. The run "
0767                 "predates the manifest-based reference system, or physmon did not "
0768                 "finish.[/red]"
0769             )
0770             raise typer.Exit(1)
0771         entries = read_manifest(candidate_manifest)
0772 
0773         # Assemble the complete set: new content comes from the run's outputs,
0774         # everything else is already in the registry.
0775         staging = Path(tmp) / "staging"
0776         staging.mkdir(parents=True, exist_ok=True)
0777         data = artifact / "data"
0778         adopted = 0
0779         for relpath, digest in entries.items():
0780             source = data / relpath
0781             if source.exists() and sha256_file(source) == digest:
0782                 target = staging / relpath
0783                 target.parent.mkdir(parents=True, exist_ok=True)
0784                 shutil.copyfile(source, target)
0785                 adopted += 1
0786         console.print(
0787             f"{adopted} file(s) taken from the run, {len(entries) - adopted} reused"
0788         )
0789 
0790         if dry_run:
0791             console.print("[yellow]Dry run: not contacting the registry[/yellow]")
0792         else:
0793             # The unchanged files are already in the registry, but the push
0794             # re-references every blob so none of them can be garbage collected
0795             materialize(entries, staging, registry, jobs)
0796             console.print(
0797                 f"Published [bold]{oras_push(registry, staging, entries)}[/bold]"
0798             )
0799 
0800     if output:
0801         write_manifest(output, entries)
0802         console.print(f"[green]Wrote {output}[/green]")
0803     else:
0804         print(render_manifest(entries), end="")
0805 
0806 
0807 if __name__ == "__main__":
0808     app()