Skip to content
Open
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: 1 addition & 1 deletion .github/actions/ai-agent-runner/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ runs:
using: "composite"
steps:
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22

Expand Down
2 changes: 1 addition & 1 deletion .github/actions/contributor-check/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ runs:
using: "composite"
steps:
- name: Setup Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci-generation-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ name: ci-generation-check
# Guards the deterministic CI workflow generator. When the manifest under
# .github/ci or the generator script changes, regenerate and fail on any drift
# so the committed YAML under .github/workflows always matches its source.
# Also validates that the hand authored composite actions under
# .github/actions/*/action.yml keep their pins in sync with the same registry.
# This workflow is intentionally hand authored because it is the meta check
# that guards generation; it is not itself generated.

Expand All @@ -14,6 +16,7 @@ on:
paths:
- '.github/ci/**'
- '.github/workflows/**'
- '.github/actions/**'
- 'scripts/ci/generate_workflows.py'
- 'tests/ci/**'

Expand Down
83 changes: 83 additions & 0 deletions scripts/ci/generate_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
optional proposal agent may edit the manifest, but it never writes YAML and the
deterministic ``--check`` job is the gate, so generation stays reproducible and
reviewable.

The hand authored composite actions under ``.github/actions/*/action.yml`` are
not produced by this script, but they still pin actions such as setup-python
or setup-node directly, so ``--check``/``--write`` also validate and sync those
pins against the same registry. Otherwise they become a second, unreviewed
place those versions live and can silently drift from it.
"""

from __future__ import annotations
Expand All @@ -32,6 +38,7 @@
REPO_ROOT = Path(__file__).resolve().parents[2]
MANIFEST_PATH = REPO_ROOT / ".github" / "ci" / "workflows.toml"
ACTIONS_PATH = REPO_ROOT / ".github" / "ci" / "actions.toml"
COMPOSITE_ACTIONS_DIR = REPO_ROOT / ".github" / "actions"

OPA_VERSION = "0.70.0"
OPA_LINUX_AMD64_SHA256 = "00d114b94fdb1606a48cccdfc73c9ccdc62c38721150131ae578d5ff3df5c084"
Expand Down Expand Up @@ -83,6 +90,78 @@ def _load_actions(path: Path) -> dict[str, str]:
return registry


def _composite_action_files() -> list[Path]:
"""Hand authored composite actions under .github/actions/*/action.yml.

These are not produced by this generator, but they still pin actions such
as setup-python or setup-node directly, so they must stay in sync with the
registry or the pin becomes a second, unreviewed source of truth.
"""
if not COMPOSITE_ACTIONS_DIR.exists():
return []
return sorted(COMPOSITE_ACTIONS_DIR.glob("*/action.yml"))


def _registry_by_action_name(actions: dict[str, str]) -> dict[str, str]:
return {value.split("@", 1)[0]: value for value in actions.values()}


def _display_path(path: Path) -> str:
try:
return str(path.relative_to(REPO_ROOT))
except ValueError:
return str(path)


# Matches a `uses:` step reference with or without a leading `- ` list marker,
# e.g. " - uses: actions/checkout@SHA # v1" or " uses: actions/x@SHA".
USES_LINE_RE = re.compile(r"^(?P<prefix>\s*(?:-\s+)?uses:\s*)(?P<value>\S.*?)\s*$")


def check_composite_action_pins(actions: dict[str, str]) -> list[str]:
"""Return one message per composite action pin that drifted from the registry."""
registry_by_name = _registry_by_action_name(actions)
drifted: list[str] = []
for path in _composite_action_files():
rel = _display_path(path)
for line in path.read_text(encoding="utf-8").splitlines():
match = USES_LINE_RE.match(line)
if not match:
continue
value = match.group("value")
name = value.split("@", 1)[0]
canonical = registry_by_name.get(name)
if canonical is not None and value != canonical:
drifted.append(
f"{rel}: '{name}' pinned to '{value}', registry expects '{canonical}'"
)
return drifted


def sync_composite_action_pins(actions: dict[str, str]) -> list[Path]:
"""Rewrite composite action `uses:` lines to match the registry. Returns changed files."""
registry_by_name = _registry_by_action_name(actions)
changed: list[Path] = []
for path in _composite_action_files():
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
touched = False
for index, line in enumerate(lines):
match = USES_LINE_RE.match(line)
if not match:
continue
value = match.group("value")
name = value.split("@", 1)[0]
canonical = registry_by_name.get(name)
if canonical is not None and value != canonical:
newline = "\n" if line.endswith("\n") else ""
lines[index] = f"{match.group('prefix')}{canonical}{newline}"
touched = True
if touched:
path.write_text("".join(lines), encoding="utf-8")
changed.append(path)
return changed


def _indent(text: str, spaces: int) -> list[str]:
pad = " " * spaces
return [f"{pad}{line}" if line else "" for line in text.splitlines()]
Expand Down Expand Up @@ -255,6 +334,7 @@ def main(argv: list[str] | None = None) -> int:

try:
outputs = build_outputs()
actions = _load_actions(ACTIONS_PATH)
except GenerationError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
Expand All @@ -264,6 +344,8 @@ def main(argv: list[str] | None = None) -> int:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
print(f"wrote {path.relative_to(REPO_ROOT)}")
for path in sync_composite_action_pins(actions):
print(f"synced {path.relative_to(REPO_ROOT)}")
return 0

drifted: list[str] = []
Expand All @@ -273,6 +355,7 @@ def main(argv: list[str] | None = None) -> int:
drifted.append(f"{rel} (missing, run --write)")
elif path.read_text(encoding="utf-8") != content:
drifted.append(f"{rel} (out of date, run --write)")
drifted.extend(check_composite_action_pins(actions))
if drifted:
print("error: generated workflows are out of date:", file=sys.stderr)
for item in drifted:
Expand Down
46 changes: 46 additions & 0 deletions tests/ci/test_generate_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,52 @@ def test_check_mode_passes_on_committed_tree():
assert gen.main(["--check"]) == 0


def test_composite_action_pins_match_registry():
actions = gen._load_actions(gen.ACTIONS_PATH)
assert gen.check_composite_action_pins(actions) == []


def test_composite_action_drift_is_detected(tmp_path, monkeypatch):
action_dir = tmp_path / "stale-action"
action_dir.mkdir()
(action_dir / "action.yml").write_text(
"runs:\n"
" using: composite\n"
" steps:\n"
" - uses: actions/checkout@0000000000000000000000000000000000000000 # v0.0.0\n",
encoding="utf-8",
)
monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", tmp_path)
actions = gen._load_actions(gen.ACTIONS_PATH)
drift = gen.check_composite_action_pins(actions)
assert len(drift) == 1
assert "stale-action/action.yml" in drift[0]
assert "actions/checkout" in drift[0]


def test_composite_action_sync_rewrites_only_the_pin_line(tmp_path, monkeypatch):
action_dir = tmp_path / "stale-action"
action_dir.mkdir()
original = (
"name: Stale\n"
"runs:\n"
" using: composite\n"
" steps:\n"
" - uses: actions/checkout@0000000000000000000000000000000000000000 # v0.0.0\n"
" with:\n"
" fetch-depth: 0\n"
)
(action_dir / "action.yml").write_text(original, encoding="utf-8")
monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", tmp_path)
actions = gen._load_actions(gen.ACTIONS_PATH)
changed = gen.sync_composite_action_pins(actions)
assert len(changed) == 1
rewritten = (action_dir / "action.yml").read_text(encoding="utf-8")
assert f"uses: {actions['checkout']}\n" in rewritten
assert "with:\n fetch-depth: 0\n" in rewritten
assert gen.check_composite_action_pins(actions) == []


def test_unpinned_action_is_rejected(tmp_path):
bad = tmp_path / "actions.toml"
bad.write_text('[checkout]\nuses = "actions/checkout@v4"\ncomment = "v4"\n', encoding="utf-8")
Expand Down
Loading