From 593cca9cbb05740f1296a0d50cdc2a98bb2ca21a Mon Sep 17 00:00:00 2001 From: forkwright Date: Wed, 26 Aug 2026 14:41:13 -0500 Subject: [PATCH 1/2] test(gate): prove the release-please waiver rejects a spoofed branch #18's fix (PR #42) paired the release-please branch shape with a user.type == 'Bot' check in both gate-attestation.yml and hybrid-gate.yml, but nothing exercised it -- actionlint checks the workflow's shape, not what the expression or the shell decides for a given author, and #19 named exactly this gap ("no fixture caller proving ... adversarial cases such as the branch-prefix bypass in #18"). Extracts the live if-expression and run-block text from both workflows (never a hand-copied duplicate, so a future edit is what gets judged, not a description of it) and drives each against a human/fork PR using the release-please branch prefix. Verified against the actual regression: a scratch copy of the pre-#42 if-expression (branch shape alone) makes the spoofed case evaluate to waived=True, so this fixture would have caught it. --- tests/release-please-waiver.sh | 122 +++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100755 tests/release-please-waiver.sh diff --git a/tests/release-please-waiver.sh b/tests/release-please-waiver.sh new file mode 100755 index 0000000..82f4519 --- /dev/null +++ b/tests/release-please-waiver.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Drive the release-please branch-shape waivers in gate-attestation.yml and +# hybrid-gate.yml against a spoofed-author fixture. +# +# WHY this exists: issue #18 found that a branch named +# `release-please--branches--x` alone bypassed the Gate-Passed trailer check +# and the AI-attribution check, because `github.head_ref` is chosen by +# whoever opens the PR. The fix (#42) paired the branch shape with a +# `user.type == 'Bot'` check nobody but GitHub can set. Nothing exercised +# that fix: actionlint checks the workflow's shape, not what the expression +# or the shell decides for a given author. This is that adversarial fixture — +# a human/fork PR using the release-please branch prefix must NOT be waived. +# +# Both extractions read the live workflow text rather than a hand-copied +# duplicate of the condition, so a future edit to the real logic is what this +# test evaluates, not a description of it that can drift out of sync. +# +# Usage: bash tests/release-please-waiver.sh +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +GA="$ROOT/.github/workflows/gate-attestation.yml" +HG="$ROOT/.github/workflows/hybrid-gate.yml" +SCRATCH=$(mktemp -d) +trap 'rm -rf "$SCRATCH"' EXIT + +rc_all=0 + +echo "== gate-attestation.yml: 'Pass trusted automation PRs' if-expression ==" + +# Extract the expression text and evaluate it with python, translating the +# small subset of GH Actions expression syntax this line uses. +python3 - "$GA" "$SCRATCH/ga_cases.txt" <<'PY' +import re, sys, pathlib + +src = pathlib.Path(sys.argv[1]).read_text() +m = re.search(r"name: Pass trusted automation PRs\n\s*if: \$\{\{(.*)\}\}", src) +if not m: + print("FAIL: could not find the 'Pass trusted automation PRs' if-expression", file=sys.stderr) + sys.exit(1) +expr = m.group(1).strip() + +def translate(expr, login, head_ref, user_type): + py = expr + py = re.sub(r"startsWith\(([^,]+),\s*'([^']*)'\)", r"\1.startswith('\2')", py) + py = py.replace("github.event.pull_request.user.login", repr(login)) + py = py.replace("github.event.pull_request.user.type", repr(user_type)) + py = py.replace("github.head_ref", repr(head_ref)) + py = py.replace("&&", " and ").replace("||", " or ") + return eval(py) + +cases = [ + # (label, login, head_ref, user_type, want_waived) + ("dependabot login", "dependabot[bot]", "dependabot/npm/x", "Bot", True), + ("release-please[bot] login", "release-please[bot]", "some-branch", "Bot", True), + ("release-please branch + Bot author", "release-please[bot]", "release-please--branches--main", "Bot", True), + ("release-please branch + PAT-owned Bot", "some-app[bot]", "release-please--branches--main", "Bot", True), + ("SPOOFED: release-please branch, User author", "attacker", "release-please--branches--main", "User", False), + ("SPOOFED: release-please branch, no type", "attacker", "release-please--branches--main", "", False), + ("ordinary PR", "someone", "feature/x", "User", False), +] + +lines = [] +ok = True +for label, login, head_ref, user_type, want in cases: + got = translate(expr, login, head_ref, user_type) + status = "pass" if got == want else "FAIL" + if got != want: + ok = False + lines.append(f" {status} {label:<48} -> waived={got} (wanted {want})") + +pathlib.Path(sys.argv[2]).write_text("\n".join(lines) + "\n") +sys.exit(0 if ok else 1) +PY +ga_rc=$? +cat "$SCRATCH/ga_cases.txt" +[ "$ga_rc" -eq 0 ] || rc_all=1 + +echo +echo "== hybrid-gate.yml: 'Verify no AI attribution' run block ==" + +# Extract the run: block of the ai-attribution step, de-indented. +python3 - "$HG" "$SCRATCH/step.sh" <<'PY' +import sys, pathlib +src = pathlib.Path(sys.argv[1]).read_text().splitlines() +start = next(i for i, l in enumerate(src) if l.strip() == 'case "$PR_HEAD_REF" in') +end = next(i for i, l in enumerate(src) if i > start and l.strip() == 'exit 1' and src[i - 1].strip().startswith("echo \"Remove AI attribution")) +end += 1 # the closing `fi` for the final `if [ "$violation" -ne 0 ]` block +body = [] +for l in src[start:end + 1]: + body.append(l[10:] if l.startswith(" " * 10) else l) +pathlib.Path(sys.argv[2]).write_text("\n".join(body) + "\n") +PY + +run_case() { + local label="$1" head_ref="$2" author="$3" author_type="$4" title="$5" want_rc="$6" + PR_HEAD_REF="$head_ref" PR_AUTHOR="$author" PR_AUTHOR_TYPE="$author_type" \ + PR_TITLE="$title" PR_BODY="" BASE_REF="" EVENT_BEFORE="" \ + bash "$SCRATCH/step.sh" >"$SCRATCH/out" 2>&1 + local got_rc=$? + if [ "$got_rc" -eq "$want_rc" ]; then + printf ' pass %-58s -> exit %s\n' "$label" "$got_rc" + else + printf ' FAIL %-58s -> exit %s (wanted %s)\n' "$label" "$got_rc" "$want_rc" + sed 's/^/ /' "$SCRATCH/out" + rc_all=1 + fi +} + +# WHY the marker sits at the START of the title: the AI-attribution pattern +# is line-anchored (^) so it does not misfire on a title merely discussing +# the policy mid-sentence — a marker embedded elsewhere in the string is a +# property of that regex, not of the release-please waiver under test here. +run_case "release-please branch, Bot author, clean title" "release-please--branches--main" "release-please[bot]" "Bot" "chore: release 1.2.3" 0 +run_case "SPOOFED branch, User author, clean title" "release-please--branches--main" "attacker" "User" "chore: release 1.2.3" 0 +run_case "SPOOFED branch, User author, AI marker in title" "release-please--branches--main" "attacker" "User" "🤖 Generated with Claude" 1 +run_case "ordinary PR, clean title" "feature/x" "someone" "User" "feat: add x" 0 +run_case "ordinary PR, AI marker in title" "feature/x" "someone" "User" "co-authored-by: claude" 1 + +echo +[ "$rc_all" -eq 0 ] && echo "all cases pass" || echo "FAILURES above" +exit "$rc_all" From 73d98bae8132ea898bb323d4d7955a07db2b044d Mon Sep 17 00:00:00 2001 From: forkwright Date: Wed, 26 Aug 2026 14:41:32 -0500 Subject: [PATCH 2/2] docs(readme): regenerate the pin, input, and rollout tables from the tree #19 named this repo's README as a defect in itself: a nonexistent gate-attestation `runner` input, four of six action pins on a stale version (actions/checkout v6 vs the v7.0.1 every workflow actually pins, setup-rust-toolchain, cargo-deny-action, actions/stale likewise), a caller-pattern section covering 4 of the 10 consumer-facing reusables, and a fleet-rollout list that named 12 repos while a live code-search shows 18 already converted with zero entries for most of them (koinon, mneme, sphragis, zetesis, typikon, thumos, heurema, gnomon among them). A hand-typed table describing a workflow file is a second copy of that file, free to diverge invisibly the moment either side changes without the other -- which is exactly what happened here. scripts/render_readme_tables.py reads the workflow_call blocks and uses: lines directly and (best-effort, network-dependent) the org via GitHub code search, and prints the three tables for splicing back in. This commit is one such splice; re-run the script and re-splice whenever the tree or the fleet's adoption moves. Also fixes the visibility-requirement section's example, which named theatron and logismos as the private consumers this repo's public visibility unblocks -- both are public. gnomon is private and genuinely consumes gate-attestation + release-please today per the same query. --- .gitignore | 2 + README.md | 135 +++++++++++++++++---- scripts/render_readme_tables.py | 209 ++++++++++++++++++++++++++++++++ 3 files changed, 322 insertions(+), 24 deletions(-) create mode 100644 .gitignore create mode 100755 scripts/render_readme_tables.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 881acd4..6cd4ea3 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,22 @@ Reusable CI workflows for the forkwright fleet. All repos call these instead of maintaining local copies. +`.github/workflows/` holds 11 files. actionlint.yml lints THIS repo's own +workflows on its own pull requests and is never called by a consumer. The +other 10 declare `workflow_call` and are the fleet reusables; every input +they accept is in [Workflow inputs](#workflow-inputs) below, generated from +those files rather than typed by hand — run +`python3 scripts/render_readme_tables.py` to refresh this README's three +generated sections (Workflow inputs, Pinned action versions, Fleet rollout) +against whatever the tree and the org currently look like, and splice its +output back in. + ## Caller pattern -Each repo keeps a thin `.github/workflows/.yml` that delegates entirely: +Each repo keeps a thin `.github/workflows/.yml` that delegates entirely. +Four of the ten reusables illustrated below; the rest follow the identical +`uses: forkwright/.github/.github/workflows/.yml@main` shape with their +own inputs from the table below. ```yaml # .github/workflows/gate-attestation.yml @@ -16,7 +29,10 @@ on: jobs: call: uses: forkwright/.github/.github/workflows/gate-attestation.yml@main - # NOTE: pass runner: self-hosted for repos on self-hosted runners + # NOTE: takes no inputs (workflow_call: {}) — runner is fixed at + # ubuntu-latest, not configurable. A repo needing hybrid-gate's real + # Rust build (fmt, check, clippy, nextest) calls hybrid-gate.yml + # instead; its inputs are in the table below. ``` ```yaml @@ -34,6 +50,7 @@ jobs: call: uses: forkwright/.github/.github/workflows/security.yml@main secrets: inherit + # NOTE: add `with: { runner: self-hosted }` for repos on self-hosted runners # NOTE: add `with: { has_private_deps: true }` for repos with private fleet deps # NOTE: add `with: { cargo_audit_timeout_minutes: 30 }` for large workspaces ``` @@ -64,39 +81,109 @@ jobs: ## Workflow inputs +Generated by `scripts/render_readme_tables.py` from each file's own +`workflow_call` block — the block itself is canonical; this table is a +read-only summary. `docs-only` is not called directly by any consumer today; +`hybrid-gate.yml` calls it internally to compute its own docs-only exemption. + | Workflow | Input | Default | Notes | |----------|-------|---------|-------| -| gate-attestation | `runner` | `ubuntu-latest` | Use `self-hosted` for private-network repos | +| codeql | `actions_timeout_minutes` | `30` | | +| codeql | `analyze_actions` | `true` | | +| codeql | `analyze_rust` | `true` | | +| codeql | `queries` | `+security-extended` | | +| codeql | `rust_timeout_minutes` | `90` | | +| codeql | `rust_toolchain` | `stable` | | +| dependabot-auto-merge | *(none)* | | | +| docs-only | `docs_only_exemption` | `true` | Compute the verdict at all. When false the job still runs and reports `docs_only=false`, so a caller can wire the dependency unconditionally and let the repo opt out by input rather than by workflow surgery. | +| gate-attestation | *(none)* | | | +| hybrid-gate | `ai_attribution_check` | `true` | Run the fleet AI-attribution check (greps PR title/body and the PR-range commit messages for co-authored-by/generated-with/robot markers naming an AI tool). Bot/release-please PRs are waived the same as the trailer check. | +| hybrid-gate | `check_cmd` | `cargo check --workspace --all-targets` | The exact compile-check command. | +| hybrid-gate | `clippy_cmd` | `cargo clippy --workspace --all-targets -- -D warnings` | The exact clippy command. | +| hybrid-gate | `docs_only_exemption` | `true` | Exempt a docs-only diff from full-gate-build even with no Gate-Passed trailer. Does not affect `ai_attribution_check`, which still runs on docs-only PRs. | +| hybrid-gate | `doctest_cmd` | `` | Optional separate doctest command (nextest does not execute doctests). Empty skips this step. | +| hybrid-gate | `fmt_cmd` | `cargo fmt --all -- --check` | The exact fmt-check command. | +| hybrid-gate | `full_gate_timeout_minutes` | `90` | Timeout for the full-gate-build job. | +| hybrid-gate | `needs_fleet_repo_token` | `false` | Set true only when this repo's Cargo.toml resolves a git dependency needing authenticated fetch. Gates the git-credential step and the FLEET_REPO_TOKEN secret requirement. | +| hybrid-gate | `nextest_cmd` | `cargo nextest run --workspace` | The exact nextest invocation. | +| hybrid-gate | `rust_cache_key` | `gate-attestation` | Swatinem/rust-cache cache key discriminator. | +| hybrid-gate | `rust_toolchain` | `` | Rust toolchain channel (e.g. "1.89", "stable"). Empty (default) auto-detects from the caller repo's own rust-toolchain.toml/rust-toolchain file — the fleet convention. Set only for a repo with no toolchain file of its own. | +| hybrid-gate | `system_packages` | `` | Space-separated apt package list to install before check/clippy/nextest. Empty skips the install step. | +| no-ai-attribution | `pattern_file` | `.github/no-ai-attribution-patterns.txt` | | +| release-please | `config_file` | `release-please-config.json` | | +| release-please | `manifest_file` | `.release-please-manifest.json` | | +| release-pr-checks | `healer_ref` | `main` | Ref of forkwright/.github to take the healer script from. Pin only to reproduce a past run. | +| release-pr-checks | `required_context_workflows` | `gate-attestation.yml,security.yml` | Comma-separated workflow FILENAMES that produce this repo's branch-protection required contexts. | +| security | `cargo_audit_timeout_minutes` | `15` | | +| security | `cargo_deny_arguments` | `` | Extra arguments passed to cargo-deny, e.g. "--all-features". | +| security | `cargo_deny_timeout_minutes` | `15` | | +| security | `has_private_deps` | `false` | Configure FLEET_REPO_TOKEN git credentials for cross-repo private deps. | +| security | `osv_config` | `osv-scanner.toml` | | +| security | `osv_lockfile` | `Cargo.lock` | | +| security | `run_osv` | `true` | Run google/osv-scanner (uploads SARIF to code scanning). | | security | `runner` | `ubuntu-latest` | | -| security | `cargo_audit_timeout_minutes` | `15` | Set `30` for large workspaces | -| security | `has_private_deps` | `false` | `true` configures FLEET_REPO_TOKEN credentials | -| stale | `days_before_issue_stale` | `60` | | -| stale | `days_before_pr_stale` | `30` | | -| stale | `days_before_close` | `14` | | +| stale | `days_before_close` | `14` | Days after stale label before closing. | +| stale | `days_before_issue_stale` | `60` | Days of inactivity before marking an issue stale. | +| stale | `days_before_pr_stale` | `30` | Days of inactivity before marking a PR stale. | ## Pinned action versions +Generated the same way, from the `uses: @ # ` lines +across `.github/workflows/*.yml`. Excludes this repo's own internal +reusable-to-reusable calls (e.g. hybrid-gate.yml → docs-only.yml), which are +pinned for the same reason but are not a fleet-consumer-facing dependency. + | Action | Version | SHA | |--------|---------|-----| -| actions/checkout | v6 | `de0fac2e4500dabe0009e67214ff5f5447ce83dd` | -| actions-rust-lang/setup-rust-toolchain | v1.16.1 | `46268bd060767258de96ed93c1251119784f2ab6` | -| Swatinem/rust-cache | v2 | `e18b497796c12c097a38f9edb9d0641fb99eee32` | -| EmbarkStudios/cargo-deny-action | v2.0.19 | `a531616d8ce3b9177443e48a1159bc945a099823` | -| actions/stale | v10.3.0 | `eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899` | +| EmbarkStudios/cargo-deny-action | v2.1.1 | `3c6349835b2b7b196a839186cb8b78e02f7b5f25` | +| Swatinem/rust-cache | v2 | `f0d9c3887740aee45f6153b24b3a6b815192ec16` | +| actions-rust-lang/setup-rust-toolchain | v1.17.0 | `166cdcfd11aee3cb47222f9ddb555ce30ddb9659` | +| actions/checkout | v7.0.1 | `3d3c42e5aac5ba805825da76410c181273ba90b1` | +| actions/stale | v11.0.0 | `4391f3da665fdf50b6810c1a66712fb9ba21aa93` | +| actions/upload-artifact | v7.0.1 | `043fb46d1a93c77aae656e7c1c64a875d1fc6a0a` | +| dependabot/fetch-metadata | v3.1.0 | `25dd0e34f4fe68f24cc83900b1fe3fe149efef98` | +| dtolnay/rust-toolchain | stable | `631a55b12751854ce901bb631d5902ceb48146f7` | +| github/codeql-action/analyze | v4 | `dd677812177e0c29f9c970a6c58d8607ae1bfefd` | +| github/codeql-action/autobuild | v4 | `dd677812177e0c29f9c970a6c58d8607ae1bfefd` | +| github/codeql-action/init | v4 | `dd677812177e0c29f9c970a6c58d8607ae1bfefd` | | googleapis/release-please-action | v5.0.0 | `45996ed1f6d02564a971a2fa1b5860e934307cf7` | - -## Remaining fleet rollout - -Repos not yet converted (busy repos excluded from initial proof): - -- aletheia, kanon, logismos, akroasis, harmonia, thumos, epistole — convert during each repo's next spotless pass -- hamma, zetesis, gnomon — convert at will (low traffic) - -Proof repos already converted: **theatron**, **dioptron**. +| taiki-e/install-action | v2.86.5 | `ba47c86ac325773530516bb756137ac718732518` | + +## Fleet rollout + +A GitHub code-search snapshot (`scripts/render_readme_tables.py`), not a +maintained list — the prior hand-typed version fell out of date the moment a +repo converted without an edit here, and stayed silently wrong afterward. The +query finds a `.github/workflows/` file containing this repo's `uses:` +prefix; it needs network + `gh` auth and does not claim completeness — a repo +absent below has no *detected* match, not a proven non-match, and a private +repo the token cannot search reads identically to one that never converted. + +| Repo | Reusables consumed | +|------|---------------------| +| forkwright/akroasis | dependabot-auto-merge, gate-attestation, release-please, release-pr-checks | +| forkwright/aletheia | gate-attestation | +| forkwright/dioptron | release-please | +| forkwright/epistole | dependabot-auto-merge, gate-attestation, release-please, release-pr-checks | +| forkwright/epitelesis | release-please, release-pr-checks | +| forkwright/gnomon | gate-attestation, release-please | +| forkwright/hamma | gate-attestation, release-please, release-pr-checks | +| forkwright/harmonia | gate-attestation, release-please, release-pr-checks | +| forkwright/heurema | gate-attestation, release-please, release-pr-checks | +| forkwright/koinon | gate-attestation, release-please, release-pr-checks | +| forkwright/logismos | gate-attestation, release-please, release-pr-checks | +| forkwright/mneme | gate-attestation, release-please | +| forkwright/pinax | gate-attestation, release-please | +| forkwright/sphragis | dependabot-auto-merge, gate-attestation, release-please, release-pr-checks | +| forkwright/theatron | codeql, dependabot-auto-merge, gate-attestation, release-please, release-pr-checks, stale | +| forkwright/thumos | gate-attestation, release-pr-checks | +| forkwright/typikon | gate-attestation, release-pr-checks | +| forkwright/zetesis | gate-attestation, release-please, release-pr-checks, security | ## Visibility requirement This repo must remain **public**. GitHub does not allow private repos to call reusable workflows from a private source repo on a personal account -(`forkwright` is a personal account, not a GitHub org). Making this repo public -unblocks both private repos (theatron, kanon, logismos, ...) and public ones. +(`forkwright` is a personal account, not a GitHub org). Making this repo +public unblocks private consumers (gnomon calls gate-attestation and +release-please today — see Fleet rollout above) as well as public ones. diff --git a/scripts/render_readme_tables.py b/scripts/render_readme_tables.py new file mode 100755 index 0000000..645d84d --- /dev/null +++ b/scripts/render_readme_tables.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Derive README.md's pinned-action, reusable-input, and fleet-rollout tables +from the tree and (best-effort) the live org, and print them for splicing in. + +WHY this exists: those three tables were hand-typed and drifted from the +workflows they describe -- a nonexistent gate-attestation `runner` input, four +of six pins on a stale version, and a rollout list that named 12 repos while +more than twice that many had already converted with no entry at all (#19). +A doc a human retypes on every change is a second copy of the workflow files +themselves, free to diverge invisibly; this reads the workflows instead. + +WHY the fleet-rollout table is best-effort: it goes through GitHub code +search, which needs network + `gh` auth and does not claim completeness (a +repo whose search index entry is stale reports as unconverted). Treat its +absence as "unknown", never as "not converted" -- an empty or failed query +prints a warning and omits the table rather than rendering a false negative. + +Usage: python3 scripts/render_readme_tables.py +Prints each table to stdout under a heading matching its README section; +splice the output in by hand. This is a derivation tool, not a writer -- it +never touches README.md itself, so a change here cannot silently rewrite docs +nobody reviewed. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +WORKFLOWS_DIR = ROOT / ".github" / "workflows" + +# WHY True, not "on": PyYAML's safe_load resolves the bare scalar key `on:` +# to the boolean True per the YAML 1.1 core schema -- this is a parser +# quirk, not a typo. Reading doc["on"] finds nothing and reports "no +# workflow_call inputs" for every file, which is silently wrong rather than +# an error. +_ON_KEY = True + + +def load_on_block(path: Path) -> dict: + doc = yaml.safe_load(path.read_text()) + on = doc.get(_ON_KEY, doc.get("on")) + return on if isinstance(on, dict) else {} + + +def pinned_actions() -> dict[str, tuple[str, str]]: + """Third-party action -> (version comment, sha), asserting fleet-wide + consistency. Excludes this repo's own reusable workflows calling each + other (e.g. hybrid-gate.yml -> docs-only.yml) -- that is internal + wiring, pinned for the same reproducibility reason, but it is not a + dependency a fleet consumer needs to know about. + """ + pattern = re.compile(r"uses:\s*([\w./-]+)@([0-9a-f]{40})\s*#\s*(\S+)") + seen: dict[str, tuple[str, str]] = {} + conflicts: list[tuple[str, tuple[str, str], tuple[str, str], Path]] = [] + for path in sorted(WORKFLOWS_DIR.glob("*.yml")): + for line in path.read_text().splitlines(): + m = pattern.search(line) + if not m: + continue + action, sha, version = m.groups() + if action.startswith("forkwright/.github/"): + continue + pin = (version, sha) + if action in seen and seen[action] != pin: + conflicts.append((action, seen[action], pin, path)) + seen[action] = pin + for action, old, new, path in conflicts: + print( + f"WARNING: {action} pinned inconsistently ({old} vs {new} in {path})", + file=sys.stderr, + ) + return seen + + +def reusable_workflows() -> dict[str, dict]: + """filename -> workflow_call block, for every file that declares one. + + A file with no `workflow_call` key (actionlint.yml, which triggers on + `pull_request` and lints THIS repo's own workflows) is not a fleet + reusable and is excluded here by construction, not by a maintained list. + """ + out: dict[str, dict] = {} + for path in sorted(WORKFLOWS_DIR.glob("*.yml")): + on = load_on_block(path) + if "workflow_call" in on: + out[path.name] = on["workflow_call"] or {} + return out + + +def render_pin_table(pins: dict[str, tuple[str, str]]) -> str: + lines = ["| Action | Version | SHA |", "|--------|---------|-----|"] + for action in sorted(pins): + version, sha = pins[action] + lines.append(f"| {action} | {version} | `{sha}` |") + return "\n".join(lines) + + +def render_inputs_table(reusables: dict[str, dict]) -> str: + lines = ["| Workflow | Input | Default | Notes |", "|----------|-------|---------|-------|"] + for name in sorted(reusables): + call = reusables[name] + wf = name.removesuffix(".yml") + inputs = call.get("inputs") or {} + if not inputs: + lines.append(f"| {wf} | *(none)* | | |") + continue + for iname in sorted(inputs): + spec = inputs[iname] or {} + default = spec.get("default", "") + # WHY lower(): YAML's true/false round-trip through PyYAML as + # Python's True/False -- a reader of this table expects the YAML + # spelling, not the Python one. + if isinstance(default, bool): + default = str(default).lower() + desc = (spec.get("description") or "").strip().split("\n")[0] + lines.append(f"| {wf} | `{iname}` | `{default}` | {desc} |") + return "\n".join(lines) + + +def fleet_consumers() -> dict[str, set[str]] | None: + """workflow filename -> consuming repo full_names, via GitHub code search. + + Returns None (not {}) on any query failure, so the caller can tell + "queried and found nothing" apart from "could not query" -- the two read + identically as an empty table otherwise, and the second must never + render as a rollout claim. + """ + try: + result = subprocess.run( + [ + "gh", "api", "-X", "GET", "search/code", "--paginate", + # WHY one token, not two: `--raw-field` (`-F`) takes a single + # `key=value` argument. Passing "q" and the value as separate + # argv elements is silently accepted by no gh subcommand -- + # it surfaces as "accepts 1 arg(s), received 2" pointing at + # the *search endpoint*, which reads as a query-syntax + # problem rather than an argv-shape one. + "--raw-field", + '''q="forkwright/.github/.github/workflows" org:forkwright''', + "--jq", r'.items[] | "\(.repository.full_name)\t\(.path)"', + ], + capture_output=True, text=True, check=True, timeout=60, + ) + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as exc: + print(f"WARNING: fleet rollout query failed ({exc}); omitting rollout table", file=sys.stderr) + return None + + consumers: dict[str, set[str]] = {} + for line in result.stdout.splitlines(): + if "\t" not in line: + continue + repo, path = line.split("\t", 1) + if repo == "forkwright/.github": + continue + # WHY this filter: kanon's ci-substrate carries the SAME reusable + # pin string inside its own `.j2` templates (the source these + # workflows get generated FROM for other repos) and in prose docs + # describing the pattern. Neither is kanon's OWN repo consuming a + # reusable -- only a hit under its actual `.github/workflows/` + # means the repo calls it. + if not path.startswith(".github/workflows/"): + continue + consumers.setdefault(Path(path).name, set()).add(repo) + return consumers + + +def render_rollout_table(consumers: dict[str, set[str]], reusables: dict[str, dict]) -> str: + all_repos = sorted({repo for repos in consumers.values() for repo in repos}) + lines = ["| Repo | Reusables consumed |", "|------|---------------------|"] + for repo in all_repos: + used = sorted( + name.removesuffix(".yml") for name in reusables if repo in consumers.get(name, set()) + ) + if not used: + continue + lines.append(f"| {repo} | {', '.join(used)} |") + return "\n".join(lines) + + +def main() -> int: + pins = pinned_actions() + reusables = reusable_workflows() + + print("## Pinned action versions\n") + print(render_pin_table(pins)) + + print("\n## Workflow inputs\n") + print(render_inputs_table(reusables)) + + consumers = fleet_consumers() + print("\n## Fleet rollout (code-search snapshot; re-run to refresh)\n") + if consumers is None: + print("(query failed -- see stderr; leaving the README section untouched)") + elif not consumers: + print("(query returned no consumers -- see stderr before trusting this as ground truth)") + else: + print(render_rollout_table(consumers, reusables)) + + return 0 + + +if __name__ == "__main__": + sys.exit(main())