From 4b83c40486c9a5bff293be0ecec590085fac9119 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" Date: Sun, 25 Oct 2026 20:36:44 +0000 Subject: [PATCH] fix(ci): the lockfile regenerator must merge, not rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging #242 took the Pages deploy on `main` down: `casket-pages` concluded `failure` in **Set up job**, with one annotation — lockfile missing pin for actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f Same workflow, same pins, same commit as before: the only thing that changed was `actions.lock`. `pin-workflow-uses.py` rebuilt the `dependencies:` section from the `uses:` lines it could see, so it deleted (a) the action that `actions/upload-pages-artifact` resolves for itself at run time and (b) every tag-keyed alias block `gh actions-lock` had written. GitHub validates the lockfile at job setup, so the workflow died before its first step and the message named an action no file of ours mentions. Regeneration now MERGES into the committed `dependencies:` blocks: existing entries are preserved verbatim (their `ref`, `commit`, `owner_id`, `repo_id` and `uses:` lists), and only missing keys are added, inheriting identity from the blocks already there. `--check` is idempotent over the merged form, so CI detects drift without being the thing that destroys the file. The blocks the earlier regeneration removed are restored. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/actions.lock | 146 ++++++++++++++++++++++++++++++++ docs/CI-CD.adoc | 10 +++ scripts/ci/pin-workflow-uses.py | 139 ++++++++++++++++++------------ 3 files changed, 241 insertions(+), 54 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 6463635..8c3d7e6 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -107,73 +107,219 @@ dependencies: commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' owner_id: 44036562 repo_id: 215566462 + 'actions/cache@v6.1.0': + ref: 'v6.1.0' + commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + owner_id: 44036562 + repo_id: 215566462 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': ref: 'v7.0.1' commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' owner_id: 44036562 repo_id: 197814629 + 'actions/checkout@v7.0.1': + ref: 'v7.0.1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 'actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d': ref: 'v6.0.0' commit: 'sha1-45bfe0192ca1faeb007ade9deae92b16b8254a0d' owner_id: 44036562 repo_id: 513659658 + 'actions/configure-pages@v6.0.0': + ref: 'v6.0.0' + commit: 'sha1-45bfe0192ca1faeb007ade9deae92b16b8254a0d' + owner_id: 44036562 + repo_id: 513659658 'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294': ref: 'v5.0.0' commit: 'sha1-a1d282b36b6f3519aa1f3fc636f609c47dddb294' owner_id: 44036562 repo_id: 476372928 + 'actions/dependency-review-action@v5.0.0': + ref: 'v5.0.0' + commit: 'sha1-a1d282b36b6f3519aa1f3fc636f609c47dddb294' + owner_id: 44036562 + repo_id: 476372928 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346': ref: 'v5.0.1' commit: 'sha1-368f82528645a54fb793d4d04e342629a3f51346' owner_id: 44036562 repo_id: 438112499 + 'actions/deploy-pages@v5.0.1': + ref: 'v5.0.1' + commit: 'sha1-368f82528645a54fb793d4d04e342629a3f51346' + owner_id: 44036562 + repo_id: 438112499 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333': ref: 'v8.0.2' commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333' owner_id: 44036562 repo_id: 192626254 + 'actions/download-artifact@v8.0.2': + ref: 'v8.0.2' + commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333' + owner_id: 44036562 + repo_id: 192626254 + 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f': + ref: 'v7.0.0' + commit: 'sha1-bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' + owner_id: 44036562 + repo_id: 192625955 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9': ref: 'v7.0.2' commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9' owner_id: 44036562 repo_id: 192625955 + 'actions/upload-artifact@v7.0.2': + ref: 'v7.0.2' + commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9' + owner_id: 44036562 + repo_id: 192625955 'actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9': ref: 'v5.0.0' commit: 'sha1-fc324d3547104276b827a68afc52ff2a11cc49c9' owner_id: 44036562 repo_id: 496012378 + 'actions/upload-pages-artifact@v5.0.0': + ref: 'v5.0.0' + commit: 'sha1-fc324d3547104276b827a68afc52ff2a11cc49c9' + owner_id: 44036562 + repo_id: 496012378 + uses: + - 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' + 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed': + ref: 'v2.0.5' + commit: 'sha1-22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' + owner_id: 42048915 + repo_id: 356423100 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8': ref: 'v1' commit: 'sha1-683b588c487a69d01f6c6ddf40f036ff682cc2c8' owner_id: 1940490 repo_id: 260749683 + 'dtolnay/rust-toolchain@v1': + ref: 'v1' + commit: 'sha1-683b588c487a69d01f6c6ddf40f036ff682cc2c8' + owner_id: 1940490 + repo_id: 260749683 + 'editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393': + ref: 'v3.0.0' + commit: 'sha1-51f63319f592f97930c73d9c46184d20bd206393' + owner_id: 26415196 + repo_id: 297874902 + 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124': + ref: 'v1.24.1' + commit: 'sha1-54075bcc5e249e4758d363f27d099f55d843f124' + owner_id: 47606891 + repo_id: 331103973 'github/codeql-action@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee': ref: 'v4.39.0' commit: 'sha1-6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee' owner_id: 9919 repo_id: 259445878 + 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63': + ref: 'v4.38.0' + commit: 'sha1-b96794f015dfd88f77b49b1c93e0fa7110f94c63' + owner_id: 9919 + repo_id: 259445878 + 'github/codeql-action@v4.39.0': + ref: 'v4.39.0' + commit: 'sha1-6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee' + owner_id: 9919 + repo_id: 259445878 + 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406': + ref: 'v2.2.1' + commit: 'sha1-abea47f85e598557f500fa1fd2ab7464fcb39406' + owner_id: 1006268 + repo_id: 212984112 'haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d': ref: 'v2.12.1' commit: 'sha1-0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' owner_id: 75048950 repo_id: 623796603 + 'haskell-actions/setup@v2.12.1': + ref: 'v2.12.1' + commit: 'sha1-0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' + owner_id: 75048950 + repo_id: 623796603 'hyperpolymath/smtp-notify-action@c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef': ref: 'v0.5.0' commit: 'sha1-c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef' owner_id: 6759885 repo_id: 1352485172 + 'hyperpolymath/smtp-notify-action@v0.5.0': + ref: 'v0.5.0' + commit: 'sha1-c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef' + owner_id: 6759885 + repo_id: 1352485172 + 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7': + ref: '333b06a01db1874ffedb2de1bfabb195640927c7' + commit: 'sha1-333b06a01db1874ffedb2de1bfabb195640927c7' + owner_id: 6759885 + repo_id: 1116521501 + uses: + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' + - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' + - 'editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393' + - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' + - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' + - 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406' + - 'ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e' + - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' + - 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6' + - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' + 'ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e': + ref: 'v2.5.0' + commit: 'sha1-fa7117db83c52d787ae5a89f350999701505d78e' + owner_id: 67707773 + repo_id: 421101922 + 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6': + ref: 'v2.2.0' + commit: 'sha1-0c5077e51419868618aeaa5fe8019c62421857d6' + owner_id: 108928776 + repo_id: 512644635 'peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697': ref: 'v4.0.1' commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' owner_id: 18365890 repo_id: 220359305 + 'peter-evans/repository-dispatch@v4.0.1': + ref: 'v4.0.1' + commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' + owner_id: 18365890 + repo_id: 220359305 'softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64': ref: 'v3.0.3' commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64' owner_id: 2242 repo_id: 204253808 + 'softprops/action-gh-release@v3.0.3': + ref: 'v3.0.3' + commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64' + owner_id: 2242 + repo_id: 204253808 + 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6': + ref: 'v2.9.2' + commit: 'sha1-6323deb102c322ba6fcbdcafc7e3dddab59af2b6' + owner_id: 580492 + repo_id: 298565987 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0': ref: 'v2.9.3' commit: 'sha1-d1527c8f67ef9af357290475d4af4b0fab8f12e0' owner_id: 580492 repo_id: 298565987 + 'swatinem/rust-cache@v2.9.3': + ref: 'v2.9.3' + commit: 'sha1-d1527c8f67ef9af357290475d4af4b0fab8f12e0' + owner_id: 580492 + repo_id: 298565987 + 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555': + ref: 'v0.10.0' + commit: 'sha1-e83874834305fe9a4a2997156cb26c5de65a8555' + owner_id: 135788 + repo_id: 208510314 diff --git a/docs/CI-CD.adoc b/docs/CI-CD.adoc index 758157a..03076c9 100644 --- a/docs/CI-CD.adoc +++ b/docs/CI-CD.adoc @@ -151,6 +151,16 @@ python3 scripts/ci/pin-workflow-uses.py --map .github/workflows/action-pins.json on the day it was taken, so a reviewer can tell an intentional bump from a silent re-point. +One hard-won rule about `actions.lock`: it is a **superset** of what `grep +uses:` finds. `gh actions-lock` also records actions that composite actions +resolve internally — `actions/upload-pages-artifact` runs +`actions/upload-artifact@bbbca2dd…` itself — and GitHub validates the lockfile +at *job setup*. A regenerator that rebuilds the file from the workflows deletes +those entries, and the only symptom is `lockfile missing pin for …` in a job +that never runs a step. `scripts/ci/pin-workflow-uses.py` therefore MERGES its +`dependencies:` section instead of rebuilding it, and `--check` (which CI runs) +is idempotent over the merged form. + == Testing: evidence-graded, not declared `proven/ledger.json` grades this repository's suite on diff --git a/scripts/ci/pin-workflow-uses.py b/scripts/ci/pin-workflow-uses.py index a698fab..7c71fc2 100755 --- a/scripts/ci/pin-workflow-uses.py +++ b/scripts/ci/pin-workflow-uses.py @@ -133,59 +133,87 @@ def refs_in(workflow: Path) -> list[str]: return sorted(set(refs)) -def build_lock(pins: dict[str, dict[str, str]], existing: str) -> str: - """Emit actions.lock: `workflows:` lists refs as written; `dependencies:` - is keyed by normalised owner/repo@sha and must carry the *tag* as `ref` - (never its own SHA — standards#1271).""" - # Seed tag/identity knowledge from the lockfile that is already committed. - # We never invent an owner_id/repo_id: if a ref is genuinely new, the - # maintainer must resolve it (scripts/ci/resolve-action-shas.sh does the - # network call) and this script refuses to guess. - ids: dict[str, list[str]] = {} - by_sha: dict[tuple[str, str], str] = {} - - def slot(key: str) -> list[str]: - return ids.setdefault(key.lower(), ["", "", ""]) - - # Parse the lockfile as entry blocks so a tag-keyed entry can lend its - # owner/repo ids to the SHA-keyed entry we are about to write. - id_by_tag: dict[tuple[str, str], tuple[str, str]] = {} - blocks: list[dict[str, str]] = [] - cur: dict[str, str] | None = None - for line in existing.splitlines(): - m = re.match(r"^ '([^']+)':$", line) - if m: - cur = {"key": m.group(1)} - blocks.append(cur) +def parse_lock(text: str) -> tuple[dict[str, list[str]], list[str]]: + """Split an existing lockfile into its dependency BLOCKS. + + The blocks are kept as raw lines because `gh actions-lock` writes things + this script does not compute — `owner_id`/`repo_id`, tag-keyed aliases, and + crucially a `uses:` list for composite actions that resolve OTHER actions at + run time (`actions/upload-pages-artifact` pulls + `actions/upload-artifact@bbbca2dd…`). Discarding those is not cosmetic: + GitHub validates the lockfile at job setup and refuses the workflow with + "lockfile missing pin for …", which is how a regenerated lockfile took the + Pages deploy down. + """ + blocks: dict[str, list[str]] = {} + current: str | None = None + in_deps = False + for line in text.splitlines(): + if line.startswith("dependencies:"): + in_deps = True + current = None continue - if cur is None: + if not in_deps: continue - m = re.match(r"^ (ref|owner_id|repo_id): '?([^'\n]*)'?$", line) + m = re.match(r"^ '([^']+)':$", line) if m: - cur[m.group(1)] = m.group(2) - for b in blocks: - key = b.get("key", "") - slug, _, ref = normalise(key) - if not key: + current = m.group(1) + blocks[current] = [line] continue - slot(key)[:2] = [b.get("owner_id", ""), b.get("repo_id", "")] - slot(key)[2] = b.get("ref", "") - if b.get("owner_id"): - id_by_tag[(slug.lower(), b.get("ref", ""))] = ( - b["owner_id"], - b.get("repo_id", ""), - ) - if SHA_RE.match(ref) and b.get("ref") and not SHA_RE.match(b["ref"]): - by_sha[(slug.lower(), ref.lower())] = b["ref"] + if current is not None: + blocks[current].append(line) + # Trim trailing blank lines accidentally captured. + for key, lines in blocks.items(): + while lines and not lines[-1].strip(): + lines.pop() + return blocks + + +def block_fields(lines: list[str]) -> dict[str, str]: + out: dict[str, str] = {} + for line in lines[1:]: + m = re.match(r"^ ([a-z_]+): '?([^'\n]*)'?$", line) + if m: + out[m.group(1)] = m.group(2) + return out + - # Tags named by the pins map (owner/repo@tag -> sha) beat nothing else. +def build_lock(pins: dict[str, dict[str, str]], existing: str) -> str: + """Emit actions.lock: `workflows:` regenerated from the files on disk; the + `dependencies:` section MERGED into what is already committed. + + Merge, not rebuild, on purpose: the lockfile is a superset of what the + workflows name — `gh actions-lock` also records the actions that composite + actions resolve internally, and it records tag-keyed aliases. A generator + that reconstructs the file from `grep uses:` silently deletes both, and the + deletion is only visible as a startup failure inside the one workflow that + relied on it. + """ + preserved = parse_lock(existing) + + # Identity knowledge: owner/repo ids, and sha -> tag, taken from the + # preserved entries so a new pin can inherit them instead of guessing. + id_by_key: dict[str, tuple[str, str]] = {} + id_by_tag: dict[tuple[str, str], tuple[str, str]] = {} + by_sha: dict[tuple[str, str], str] = {} + for key, lines in preserved.items(): + fields = block_fields(lines) + slug, _, ref = normalise(key) + if fields.get("owner_id"): + id_by_key[key.lower()] = (fields["owner_id"], fields.get("repo_id", "")) + if fields.get("ref"): + id_by_tag[(slug.lower(), fields["ref"])] = ( + fields["owner_id"], + fields.get("repo_id", ""), + ) + if SHA_RE.match(ref) and not SHA_RE.match(fields["ref"]): + by_sha[(slug.lower(), ref.lower())] = fields["ref"] for key, info in pins.items(): slug, _, tag = normalise(key) sha = info.get("sha", "") if SHA_RE.match(sha) and tag and not SHA_RE.match(tag): by_sha[(slug.lower(), sha.lower())] = tag - # A `# vX.Y.Z` comment on a pinned ref is the reviewer-facing tag. comment_tag: dict[tuple[str, str], str] = {} for wf in workflow_files(): for line in wf.read_text(encoding="utf-8").splitlines(): @@ -200,30 +228,36 @@ def slot(key: str) -> list[str]: comment_tag[(slug.lower(), gitref.lower())] = comment per_file: dict[str, list[str]] = {} - deps: dict[str, tuple[str, str, str, str]] = {} + new_blocks: dict[str, list[str]] = {} missing: list[str] = [] for wf in workflow_files(): entries = [] for ref in refs_in(wf): slug, path, gitref = normalise(ref) entries.append(f"{slug.lower()}@{gitref}") - # The lockfile models ACTIONS. A reusable workflow - # (owner/repo/.github/workflows/x.yml@sha) is not keyed. if ".github/workflows/" in path or not SHA_RE.match(gitref): continue key = f"{slug.lower()}@{gitref.lower()}" + if key in preserved: + continue # keep the committed block verbatim tag = ( comment_tag.get((slug.lower(), gitref.lower())) or by_sha.get((slug.lower(), gitref.lower())) or pins.get(f"{slug}@{gitref}", {}).get("tag", "") ) - owner_id, repo_id = slot(key)[:2] + owner_id, repo_id = id_by_key.get(key, ("", "")) if not (owner_id and repo_id): owner_id, repo_id = id_by_tag.get((slug.lower(), tag or ""), ("", "")) if not (tag and not SHA_RE.match(tag) and owner_id and repo_id): missing.append(f"{slug}@{gitref}") continue - deps[key] = (tag, owner_id, repo_id, gitref.lower()) + new_blocks[key] = [ + f" '{key}':", + f" ref: '{tag}'", + f" commit: 'sha1-{gitref.lower()}'", + f" owner_id: {owner_id}", + f" repo_id: {repo_id}", + ] per_file[f".github/workflows/{wf.name}"] = sorted(set(entries)) if missing: @@ -248,13 +282,10 @@ def slot(key: str) -> list[str]: for e in per_file[path]: out.append(f" - '{e}'") out.append("dependencies:") - for key in sorted(deps): - tag, owner_id, repo_id, sha = deps[key] - out.append(f" '{key}':") - out.append(f" ref: '{tag}'") - out.append(f" commit: 'sha1-{sha}'") - out.append(f" owner_id: {owner_id}") - out.append(f" repo_id: {repo_id}") + merged = dict(preserved) + merged.update(new_blocks) + for key in sorted(merged): + out.extend(merged[key]) return "\n".join(out) + "\n"