-
Notifications
You must be signed in to change notification settings - Fork 1
[codex] Add workflow action pin contract #1925
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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): | ||
| 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()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The parser treats the raw
usestoken as-is, so YAML-quoted action refs are silently skipped by the enforcement. For example,uses: "actions/checkout@v6"yields anactionvalue of"actions/checkout, which fails_is_checked_action(...)and bypasses both SHA and version-comment validation. Since quotedusessyntax 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 👍 / 👎.