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
1 change: 1 addition & 0 deletions .cspell-repo-terms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,7 @@ spellcheck
spellchecking
spiffesvid
splitlines
keepends
sprintf
stacklevel
startswith
Expand Down
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@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
Comment thread
Ricky-G marked this conversation as resolved.
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@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"

Expand Down
8 changes: 4 additions & 4 deletions .github/ci/actions.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Pinned GitHub Actions registry for the CI workflow generator.
#
# Every action referenced by a generated workflow MUST resolve to an entry here.
# Every action referenced by a generated workflow or a local composite action
# under .github/actions MUST resolve to an entry here.
# Each entry pins a full commit SHA plus a human readable version comment, which
# is the repository convention enforced across .github/workflows. The generator
# fails closed when a workflow references an action key that is missing here or
# when an entry is not pinned to a 40 character SHA.
# is the repository-wide convention enforced by the generator. It fails closed
# when a workflow or composite action uses an unknown action or an invalid pin.

[checkout]
uses = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"
Expand Down
7 changes: 4 additions & 3 deletions .github/workflows/ci-generation-check.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,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.
# Guards the deterministic CI workflow generator. When its manifest, registry,
# or generator changes, regenerate and fail on drift. It also checks that
# hand-authored composite action pins under .github/actions match the registry.
# This workflow is intentionally hand authored because it is the meta check
# that guards generation; it is not itself generated.

Expand All @@ -13,6 +13,7 @@ on:
pull_request:
paths:
- '.github/ci/**'
- '.github/actions/**'
- '.github/workflows/**'
- 'agent-governance-python/agt-policies/**'
- 'agent-governance-python/agent-governance-toolkit-core/pyproject.toml'
Expand Down
166 changes: 160 additions & 6 deletions scripts/ci/generate_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
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`` are also checked
against the same action registry, and ``--write`` synchronizes their pins.
"""

from __future__ import annotations
Expand All @@ -32,6 +35,11 @@
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"
COMPOSITE_ACTION_USES_RE = re.compile(
r"^(?P<prefix>[ \t]*(?:-[ \t]+)?uses:[ \t]*)"
r"(?P<value>[^\r\n]*?)(?P<newline>\r?\n)?$"
)

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


def _registry_by_action_name(actions: dict[str, str]) -> dict[str, str]:
registry: dict[str, str] = {}
for value in actions.values():
reference = value.split(" #", 1)[0]
action_name, separator, _revision = reference.partition("@")
if not separator:
raise GenerationError(
f"registry entry is not pinned to a revision: {value}"
)
previous = registry.get(action_name)
if previous is not None and previous != value:
raise GenerationError(
f"conflicting registry pins for action '{action_name}'"
)
registry[action_name] = value
return registry


def _composite_action_files() -> list[Path]:
if not COMPOSITE_ACTIONS_DIR.is_dir():
raise GenerationError(
f"missing composite actions directory: {COMPOSITE_ACTIONS_DIR}"
)
paths = {
path
for pattern in ("*/action.yml", "*/action.yaml")
for path in COMPOSITE_ACTIONS_DIR.glob(pattern)
}
return sorted(paths)


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


def _read_composite_action_lines(path: Path) -> list[str]:
try:
with path.open("r", encoding="utf-8", newline="") as handle:
return handle.read().splitlines(keepends=True)
except (OSError, UnicodeError) as exc:
raise GenerationError(
f"cannot read composite action file {_display_path(path)}: {exc}"
) from exc


def _inspect_composite_action_pins(
actions: dict[str, str],
) -> tuple[list[str], list[str], dict[Path, str]]:
registry_by_name = _registry_by_action_name(actions)
drifted: list[str] = []
invalid: list[str] = []
rewrites: dict[Path, str] = {}

for path in _composite_action_files():
rel = _display_path(path)
lines = _read_composite_action_lines(path)
updated_lines: list[str] = []
changed = False
for line in lines:
match = COMPOSITE_ACTION_USES_RE.match(line)
if not match:
updated_lines.append(line)
continue

value = match.group("value").strip()
reference = value.split(maxsplit=1)[0] if value else ""
if not reference:
invalid.append(f"{rel}: uses step has no action reference")
updated_lines.append(line)
continue
if reference.startswith(("./", "../", "docker://")):
updated_lines.append(line)
continue

action_name = reference.split("@", 1)[0]
canonical = registry_by_name.get(action_name)
if canonical is None:
invalid.append(
f"{rel}: '{reference}' is not registered in .github/ci/actions.toml"
)
updated_lines.append(line)
continue

if value != canonical:
drifted.append(
f"{rel}: '{reference}' does not match registry pin '{canonical}'"
)
updated_lines.append(
f"{match.group('prefix')}{canonical}{match.group('newline') or ''}"
)
changed = True
else:
updated_lines.append(line)

if changed:
rewrites[path] = "".join(updated_lines)

return drifted, invalid, rewrites


def check_composite_action_pins(actions: dict[str, str]) -> list[str]:
"""Return drift and unregistered remote action references in local actions."""
drifted, invalid, _rewrites = _inspect_composite_action_pins(actions)
return invalid + drifted


def sync_composite_action_pins(actions: dict[str, str]) -> list[Path]:
"""Rewrite registered composite action references to match the registry."""
_drifted, invalid, rewrites = _inspect_composite_action_pins(actions)
if invalid:
raise GenerationError("\n".join(invalid))

changed: list[Path] = []
for path, content in rewrites.items():
try:
with path.open("w", encoding="utf-8", newline="") as handle:
handle.write(content)
except (OSError, UnicodeError) as exc:
raise GenerationError(
f"cannot write composite action file {_display_path(path)}: {exc}"
) from exc
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 @@ -229,8 +365,9 @@ def render_workflow(workflow: dict, actions: dict[str, str]) -> str:
return "\n".join(lines) + "\n"


def build_outputs() -> dict[Path, str]:
actions = _load_actions(ACTIONS_PATH)
def build_outputs(actions: dict[str, str] | None = None) -> dict[Path, str]:
if actions is None:
actions = _load_actions(ACTIONS_PATH)
manifest = _load_toml(MANIFEST_PATH)
workflows = manifest.get("workflow", [])
if not workflows:
Expand All @@ -254,16 +391,25 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)

try:
outputs = build_outputs()
actions = _load_actions(ACTIONS_PATH)
outputs = build_outputs(actions)
except GenerationError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2

if args.write:
try:
synced = sync_composite_action_pins(actions)
except GenerationError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
for path, content in outputs.items():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
with path.open("w", encoding="utf-8", newline="\n") as handle:
handle.write(content)
print(f"wrote {path.relative_to(REPO_ROOT)}")
for path in synced:
print(f"synced {_display_path(path)}")
return 0

drifted: list[str] = []
Expand All @@ -273,12 +419,20 @@ 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)")
try:
drifted.extend(check_composite_action_pins(actions))
except GenerationError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if drifted:
print("error: generated workflows are out of date:", file=sys.stderr)
print(
"error: generated workflows or composite action pins are out of date:",
file=sys.stderr,
)
for item in drifted:
print(f" - {item}", file=sys.stderr)
return 1
print("generated workflows are up to date")
print("generated workflows and composite action pins are up to date")
return 0


Expand Down
63 changes: 63 additions & 0 deletions tests/ci/test_generate_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,69 @@ def test_check_mode_passes_on_committed_tree():
assert gen.main(["--check"]) == 0


def test_check_mode_detects_composite_action_pin_drift(tmp_path, monkeypatch, capsys):
action_dir = tmp_path / "stale-action"
action_dir.mkdir()
(action_dir / "action.yml").write_text(
"runs:\n"
" using: composite\n"
" steps:\n"
" - uses: actions/setup-python@0000000000000000000000000000000000000000 # v0.0.0\n",
encoding="utf-8",
)
monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", tmp_path)

assert gen.main(["--check"]) == 1
captured = capsys.readouterr()
assert "stale-action" in captured.err
assert "actions/setup-python" in captured.err


def test_write_mode_syncs_composite_action_pins(tmp_path, monkeypatch):
action_dir = tmp_path / "stale-action"
action_dir.mkdir()
action_file = action_dir / "action.yaml"
action_file.write_text(
"runs:\n"
" using: composite\n"
" steps:\n"
" - uses: actions/checkout@0000000000000000000000000000000000000000 # v0.0.0\n"
" with:\n"
" fetch-depth: 0\n",
encoding="utf-8",
)
monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", tmp_path)
monkeypatch.setattr(gen, "build_outputs", lambda *_args: {})
actions = gen._load_actions(gen.ACTIONS_PATH)

assert gen.main(["--write"]) == 0
rewritten = action_file.read_text(encoding="utf-8")
assert f"uses: {actions['checkout']}\n" in rewritten
assert "with:\n fetch-depth: 0\n" in rewritten


def test_unregistered_composite_action_reference_fails_closed(tmp_path, monkeypatch):
action_dir = tmp_path / "unknown-action"
action_dir.mkdir()
action_file = action_dir / "action.yml"
original = (
"runs:\n"
" using: composite\n"
" steps:\n"
" - uses: actions/checkout@0000000000000000000000000000000000000000 # v0.0.0\n"
" - uses: unregistered/action@0000000000000000000000000000000000000000\n"
)
action_file.write_text(original, encoding="utf-8")
monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", tmp_path)
actions = gen._load_actions(gen.ACTIONS_PATH)

issues = gen.check_composite_action_pins(actions)
assert any("unregistered/action" in issue for issue in issues)
with pytest.raises(gen.GenerationError, match="not registered"):
gen.sync_composite_action_pins(actions)
assert action_file.read_text(encoding="utf-8") == original


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