Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
135 changes: 111 additions & 24 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<name>.yml` that delegates entirely:
Each repo keeps a thin `.github/workflows/<name>.yml` that delegates entirely.
Four of the ten reusables illustrated below; the rest follow the identical
`uses: forkwright/.github/.github/workflows/<name>.yml@main` shape with their
own inputs from the table below.

```yaml
# .github/workflows/gate-attestation.yml
Expand All@@ -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
Expand All@@ -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
```
Expand DownExpand Up@@ -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: <action>@<sha> # <version>` 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.
209 changes: 209 additions & 0 deletions scripts/render_readme_tables.py
Original file line numberDiff line numberDiff line change
@@ -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())
Loading