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"