File indexing completed on 2026-07-26 08:18:02
0001
0002
0003
0004
0005
0006
0007
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
0040
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
0058
0059 BUILDS_WORKFLOW = "builds.yml"
0060 ARTIFACT = "physmon"
0061
0062
0063
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
0082
0083
0084
0085
0086
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
0113
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
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
0161
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
0257
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
0278
0279
0280 handle, staged = tempfile.mkstemp(dir=dest.parent, prefix=f"{digest}.")
0281 tmp = Path(staged)
0282 try:
0283 try:
0284
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
0293
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
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
0335
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
0401
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
0425
0426
0427
0428
0429
0430
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
0467
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
0518
0519
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
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
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
0774
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
0794
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()