mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
i8cifail died twice on the same run, each failure hiding the next. The retrigger recovers a source run's dispatch inputs from a job log, because the title truncates at 512 bytes and REST answers inputs: null for a finished workflow_dispatch. That reader picked the first depth-1 job outright. A skipped job never uploads a log blob, so its log endpoint answers BlobNotFound as a bare HTTP 404 -- and a force-cancelled run can leave every depth-1 job skipped, which is why deeper jobs are now read too rather than assumed inputs-free. Candidates are taken shallowest first, so the orchestrator's own inputs still win over what a caller forwarded. gh 2.97 also refuses to emit a log holding terminal escape sequences and exits 1; the log downloader read that as no log and silently wrote none. A missed fetch is now tolerated instead of aborting, so the guard in dispatched_priority reports an expired log in words rather than the command dying on gh: HTTP 404. Behind that sat the resolver: it decoded whole priority entries, but an entry may pin axes (web-app-zammad#1@compose+tor), which the display-name codec does not parse. Every pinned entry aborted as corrupt. split_axes -- already the seam decode_list uses -- separates name from suffix, so only the name is resolved and the truncation guard stays sharp. The pin rides along into the retrigger: the entry never ran, so the run holds no evidence its axes were wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
68 lines
1.9 KiB
Python
68 lines
1.9 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import random
|
|
import re
|
|
import shutil
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
|
|
from .github import gh_proc
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
_SAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
|
|
|
|
|
def jitter(delay_max: float) -> None:
|
|
# Spread the gh API calls over time to avoid rate limits; not security sensitive.
|
|
time.sleep(random.uniform(1, delay_max)) # noqa: S311 - non-crypto jitter for download backoff
|
|
|
|
|
|
def download_log(
|
|
owner: str, repo: str, job: dict, dest: Path, delay_max: float
|
|
) -> tuple[str, bool]:
|
|
jitter(delay_max)
|
|
safe = _SAFE.sub("-", job["name"]).strip("-")
|
|
proc = gh_proc(
|
|
[
|
|
"api",
|
|
"--allow-escape-sequences",
|
|
f"repos/{owner}/{repo}/actions/jobs/{job['id']}/logs",
|
|
]
|
|
)
|
|
if proc.returncode != 0 or not proc.stdout:
|
|
return job["name"], False
|
|
(dest / f"{job['id']}__{safe}.log").write_text(proc.stdout, encoding="utf-8")
|
|
return job["name"], True
|
|
|
|
|
|
def download_artifact(
|
|
owner: str, repo: str, run: str, name: str, dest: Path, delay_max: float
|
|
) -> tuple[str, bool]:
|
|
jitter(delay_max)
|
|
target = dest / name
|
|
shutil.rmtree(target, ignore_errors=True)
|
|
proc = gh_proc(
|
|
["run", "download", run, "-R", f"{owner}/{repo}", "-n", name, "-D", str(target)]
|
|
)
|
|
return name, proc.returncode == 0
|
|
|
|
|
|
def write_manifest(jobs: list[dict], dest: Path) -> None:
|
|
(dest / "jobs.json").write_text(json.dumps(jobs, indent=2), encoding="utf-8")
|
|
rows = ["id\tconclusion\tstatus\tname"]
|
|
rows += [
|
|
"\t".join(
|
|
[
|
|
str(j.get("id", "")),
|
|
j.get("conclusion") or "",
|
|
j.get("status") or "",
|
|
j.get("name") or "",
|
|
]
|
|
)
|
|
for j in jobs
|
|
]
|
|
(dest / "summary.tsv").write_text("\n".join(rows) + "\n", encoding="utf-8")
|