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
17 changes: 17 additions & 0 deletions .github/workflows/health-70-validate-sync-manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ on:
- '.github/workflows/**'
- '.github/codex/**'
- '.github/scripts/**'
- 'scripts/check_workflow_action_pins.py'
- 'templates/consumer-repo/**'
push:
branches: [main]
Expand All @@ -26,6 +27,7 @@ on:
- '.github/workflows/**'
- '.github/codex/**'
- '.github/scripts/**'
- 'scripts/check_workflow_action_pins.py'
- 'templates/consumer-repo/**'

permissions:
Expand Down Expand Up @@ -66,6 +68,21 @@ jobs:
--manifest ".github/sync-manifest.yml" \
--source "sync-manifest" \
--strict
- name: Validate workflow action pins
run: |
mkdir -p artifacts/workflow-action-pins
python scripts/check_workflow_action_pins.py \
.github/workflows/agents-verify-to-new-pr.yml \
templates/consumer-repo/.github/workflows \
--output-json artifacts/workflow-action-pins/workflow-action-pins.json \
--output-md artifacts/workflow-action-pins/workflow-action-pins.md
- name: Upload action pin contract
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: workflow-action-pins
path: artifacts/workflow-action-pins/
if-no-files-found: warn
- name: Comment on PR
if: failure() && github.event_name == 'pull_request'
uses: actions/github-script@v9
Expand Down
219 changes: 219 additions & 0 deletions scripts/check_workflow_action_pins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""Validate that selected workflow action references are commit-SHA pinned."""

from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any

SCHEMA = "workflow-action-pins/v1"
SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$")
USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*(?P<uses>[^\s#]+)(?P<comment>\s*#.*)?$")
VERSION_COMMENT_RE = re.compile(r"#\s*v\d+(?:[.\w-]+)?\b")


def _iter_workflow_files(paths: list[Path]) -> list[Path]:
files: list[Path] = []
seen: set[Path] = set()
for path in paths:
if path.is_dir():
candidates = sorted(
child
for child in path.rglob("*")
if child.is_file() and child.suffix in {".yml", ".yaml"}
)
else:
candidates = [path]
for candidate in candidates:
resolved = candidate.resolve()
if resolved not in seen:
seen.add(resolved)
files.append(candidate)
return files


def _is_checked_action(action: str, prefixes: tuple[str, ...]) -> bool:
return any(action.startswith(prefix) for prefix in prefixes)


def _format_path(path: Path) -> str:
try:
return path.relative_to(Path.cwd()).as_posix()
except ValueError:
return path.as_posix()


def check_file(
path: Path,
*,
prefixes: tuple[str, ...] = ("actions/",),
require_version_comment: bool = True,
) -> tuple[int, list[dict[str, Any]]]:
"""Return checked action count and pinning issues for one workflow file."""
issues: list[dict[str, Any]] = []
checked_count = 0
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError as exc:
return 0, [
{
"path": _format_path(path),
"line": 0,
"uses": "",
"action": "",
"ref": "",
"reason": "read-error",
"message": f"Unable to read workflow file: {exc}",
}
]

for line_number, line in enumerate(lines, 1):
match = USES_RE.match(line)
if not match:
continue

uses = match.group("uses")
action, separator, ref = uses.rpartition("@")
if not separator or not _is_checked_action(action, prefixes):
Comment on lines +79 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Normalize quoted uses values before prefix matching

The parser treats the raw uses token as-is, so YAML-quoted action refs are silently skipped by the enforcement. For example, uses: "actions/checkout@v6" yields an action value of "actions/checkout, which fails _is_checked_action(...) and bypasses both SHA and version-comment validation. Since quoted uses syntax is valid YAML (and already used in this repo for other actions), this creates an easy path for floating refs to evade the new pin contract.

Useful? React with 👍 / 👎.

continue

checked_count += 1
comment = match.group("comment") or ""
if not SHA_RE.match(ref):
issues.append(
{
"path": _format_path(path),
"line": line_number,
"uses": uses,
"action": action,
"ref": ref,
"reason": "floating-ref",
"message": "Action reference must use a 40-character commit SHA.",
}
)
continue

if require_version_comment and not VERSION_COMMENT_RE.search(comment):
issues.append(
{
"path": _format_path(path),
"line": line_number,
"uses": uses,
"action": action,
"ref": ref,
"reason": "missing-version-comment",
"message": "Pinned action SHA must keep a readable '# vN' version comment.",
}
)

return checked_count, issues


def build_report(
paths: list[Path],
*,
prefixes: tuple[str, ...] = ("actions/",),
require_version_comment: bool = True,
) -> dict[str, Any]:
files = _iter_workflow_files(paths)
issues: list[dict[str, Any]] = []
checked_uses_count = 0
for path in files:
file_count, file_issues = check_file(
path,
prefixes=prefixes,
require_version_comment=require_version_comment,
)
checked_uses_count += file_count
issues.extend(file_issues)

return {
"schema": SCHEMA,
"status": "pass" if not issues else "fail",
"checked_files": [_format_path(path) for path in files],
"checked_file_count": len(files),
"checked_uses_count": checked_uses_count,
"prefixes": list(prefixes),
"require_version_comment": require_version_comment,
"issue_count": len(issues),
"issues": issues,
}


def format_markdown(report: dict[str, Any]) -> str:
lines = [
"# Workflow Action Pin Report",
"",
f"- Schema: `{report['schema']}`",
f"- Status: `{report['status']}`",
f"- Files checked: {report['checked_file_count']}",
f"- Action references checked: {report['checked_uses_count']}",
f"- Issues: {report['issue_count']}",
]
if report["issues"]:
lines.extend(["", "| Path | Line | Uses | Reason |", "| --- | ---: | --- | --- |"])
for issue in report["issues"]:
lines.append(
"| {path} | {line} | `{uses}` | {reason} |".format(
path=issue["path"],
line=issue["line"],
uses=issue["uses"],
reason=issue["reason"],
)
)
return "\n".join(lines) + "\n"


def _write_outputs(
report: dict[str, Any], output_json: Path | None, output_md: Path | None
) -> None:
if output_json is not None:
output_json.parent.mkdir(parents=True, exist_ok=True)
output_json.write_text(
json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
if output_md is not None:
output_md.parent.mkdir(parents=True, exist_ok=True)
output_md.write_text(format_markdown(report), encoding="utf-8")


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Validate selected workflow action references are SHA-pinned."
)
parser.add_argument("paths", nargs="+", type=Path, help="Workflow files or directories")
parser.add_argument(
"--prefix",
action="append",
dest="prefixes",
default=None,
help="Action prefix to enforce, e.g. actions/ (repeatable)",
)
parser.add_argument(
"--no-require-version-comment",
action="store_true",
help="Allow pinned SHAs without '# vN' comments.",
)
parser.add_argument("--output-json", type=Path, help="Write machine-readable report JSON")
parser.add_argument("--output-md", type=Path, help="Write markdown report")
return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
report = build_report(
args.paths,
prefixes=tuple(args.prefixes or ["actions/"]),
require_version_comment=not args.no_require_version_comment,
)
_write_outputs(report, args.output_json, args.output_md)
print(format_markdown(report), end="")
return 0 if report["status"] == "pass" else 1


if __name__ == "__main__":
sys.exit(main())
139 changes: 139 additions & 0 deletions tests/scripts/test_check_workflow_action_pins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import json
from pathlib import Path

from scripts import check_workflow_action_pins

PINNED_CHECKOUT = "de0fac2e4500dabe0009e67214ff5f5447ce83dd"
PINNED_GITHUB_SCRIPT = "3a2844b7e9c422d3c10d287c895573f7108da1b3"


def test_build_report_accepts_sha_pinned_actions_with_version_comments(tmp_path: Path) -> None:
workflow = tmp_path / "workflow.yml"
workflow.write_text(
f"""
name: pinned
on: push
jobs:
test:
steps:
- uses: actions/checkout@{PINNED_CHECKOUT} # v6
- name: Script
uses: actions/github-script@{PINNED_GITHUB_SCRIPT} # v9
""",
encoding="utf-8",
)

report = check_workflow_action_pins.build_report([workflow])

assert report["status"] == "pass"
assert report["checked_uses_count"] == 2
assert report["issues"] == []


def test_build_report_flags_floating_action_refs(tmp_path: Path) -> None:
workflow = tmp_path / "workflow.yml"
workflow.write_text(
"""
name: floating
on: push
jobs:
test:
steps:
- uses: actions/checkout@v6
""",
encoding="utf-8",
)

report = check_workflow_action_pins.build_report([workflow])

assert report["status"] == "fail"
assert report["issues"][0]["reason"] == "floating-ref"
assert report["issues"][0]["uses"] == "actions/checkout@v6"


def test_build_report_flags_sha_without_readable_version_comment(tmp_path: Path) -> None:
workflow = tmp_path / "workflow.yml"
workflow.write_text(
f"""
name: missing-comment
on: push
jobs:
test:
steps:
- uses: actions/checkout@{PINNED_CHECKOUT}
""",
encoding="utf-8",
)

report = check_workflow_action_pins.build_report([workflow])

assert report["status"] == "fail"
assert report["issues"][0]["reason"] == "missing-version-comment"


def test_build_report_ignores_local_actions_and_reusable_workflows(tmp_path: Path) -> None:
workflow = tmp_path / "workflow.yml"
workflow.write_text(
"""
name: ignored
on: push
jobs:
test:
steps:
- uses: ./.github/actions/setup-api-client
reusable:
uses: stranske/Workflows/.github/workflows/reusable-codex-run.yml@main
""",
encoding="utf-8",
)

report = check_workflow_action_pins.build_report([workflow])

assert report["status"] == "pass"
assert report["checked_uses_count"] == 0


def test_main_writes_machine_readable_outputs(tmp_path: Path) -> None:
workflow = tmp_path / "workflow.yml"
output_json = tmp_path / "report.json"
output_md = tmp_path / "report.md"
workflow.write_text(
f"""
name: pinned
on: push
jobs:
test:
steps:
- uses: actions/checkout@{PINNED_CHECKOUT} # v6
""",
encoding="utf-8",
)

exit_code = check_workflow_action_pins.main(
[
str(workflow),
"--output-json",
str(output_json),
"--output-md",
str(output_md),
]
)

assert exit_code == 0
assert json.loads(output_json.read_text(encoding="utf-8"))["schema"] == (
"workflow-action-pins/v1"
)
assert "Workflow Action Pin Report" in output_md.read_text(encoding="utf-8")


def test_current_consumer_template_actions_are_pinned() -> None:
report = check_workflow_action_pins.build_report(
[
Path(".github/workflows/agents-verify-to-new-pr.yml"),
Path("templates/consumer-repo/.github/workflows"),
]
)

assert report["status"] == "pass"
assert report["checked_file_count"] >= 30
assert report["checked_uses_count"] >= 1
Loading