Skip to content

Commit c118b5f

Browse files
committed
Add GitHub Actions annotations output
1 parent f529586 commit c118b5f

7 files changed

Lines changed: 100 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## v0.2.0 - 2026-07-10
4+
5+
- Added `--format github-annotations` for inline GitHub Actions workflow commands.
6+
- Documented annotation output for CI users.
7+
38
## v0.1.0 - 2026-06-16
49

510
- Initial release of `action-pin-check`.

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ Return JSON for automation:
7878
action-pin-check --format json --fail-on never
7979
```
8080

81+
Emit GitHub Actions workflow annotations for inline CI findings:
82+
83+
```bash
84+
action-pin-check --format github-annotations --fail-on error
85+
```
86+
8187
Fail CI only on hard errors, not version-tag warnings:
8288

8389
```bash
@@ -118,7 +124,6 @@ Local actions like `./local-action` and Docker actions like `docker://...` are i
118124

119125
- Optional SARIF output for GitHub code scanning.
120126
- Config file for allowed tag refs.
121-
- Inline GitHub Actions annotations.
122127
- Safer fix suggestions that include action repository links.
123128
- Reusable workflow coverage.
124129

RELEASE_NOTES.md

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
1-
# action-pin-check v0.1.0
1+
# action-pin-check v0.2.0
22

3-
Initial release.
3+
Feature release.
44

5-
`action-pin-check` audits GitHub Actions workflows for external action refs that are missing, branch-based, tag-based, or short SHA pins. It is local-first, dependency-free at runtime, and supports both text and JSON output.
5+
`action-pin-check` audits GitHub Actions workflows for external action refs that are missing, branch-based, tag-based, or short SHA pins. It is local-first, dependency-free at runtime, and now supports text, JSON, and GitHub Actions annotation output.
66

77
Included:
88

9-
- CLI scanner for repository roots, workflow directories, or workflow files.
10-
- Text output for humans.
11-
- JSON output for automation.
12-
- `--fail-on` policy for CI gates.
13-
- Example unsafe workflow fixture.
14-
- Unit tests and GitHub Actions CI.
9+
- `--format github-annotations` for inline CI findings on exact workflow lines.
10+
- Existing text output for humans.
11+
- Existing JSON output for automation.
12+
- Existing `--fail-on` policy for CI gates.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "action-pin-check"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
description = "Audit GitHub Actions workflows for mutable or missing action pins."
99
readme = "README.md"
1010
requires-python = ">=3.9"

src/action_pin_check/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Audit GitHub Actions workflows for mutable or missing action pins."""
22

3-
__version__ = "0.1.0"
3+
__version__ = "0.2.0"

src/action_pin_check/cli.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def main(argv: Sequence[str] | None = None) -> int:
2222
)
2323
parser.add_argument(
2424
"--format",
25-
choices=("text", "json"),
25+
choices=("text", "json", "github-annotations"),
2626
default="text",
2727
help="Output format.",
2828
)
@@ -38,6 +38,10 @@ def main(argv: Sequence[str] | None = None) -> int:
3838
result = scan_path(args.path)
3939
if args.format == "json":
4040
print(json.dumps(result.to_dict(), indent=2, sort_keys=True))
41+
elif args.format == "github-annotations":
42+
annotations = _format_github_annotations(result)
43+
if annotations:
44+
print(annotations)
4145
else:
4246
print(_format_text(result))
4347

@@ -78,5 +82,38 @@ def _format_text(result: ScanResult) -> str:
7882
return "\n".join(lines).rstrip()
7983

8084

85+
def _format_github_annotations(result: ScanResult) -> str:
86+
lines = []
87+
for finding in result.findings:
88+
command = "error" if finding.severity == "error" else "warning"
89+
props = [
90+
f"file={_escape_annotation_property(finding.file)}",
91+
f"title={_escape_annotation_property(finding.code)}",
92+
]
93+
if finding.line > 0:
94+
props.insert(1, f"line={finding.line}")
95+
ref = f"@{finding.ref}" if finding.ref else ""
96+
message = (
97+
f"{finding.message} Fix: {finding.suggestion} "
98+
f"uses: {finding.action}{ref}".rstrip()
99+
)
100+
lines.append(
101+
f"::{command} {','.join(props)}::{_escape_annotation_data(message)}"
102+
)
103+
return "\n".join(lines)
104+
105+
106+
def _escape_annotation_data(value: str) -> str:
107+
return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
108+
109+
110+
def _escape_annotation_property(value: str) -> str:
111+
return (
112+
_escape_annotation_data(value)
113+
.replace(":", "%3A")
114+
.replace(",", "%2C")
115+
)
116+
117+
81118
if __name__ == "__main__":
82119
raise SystemExit(main(sys.argv[1:]))

tests/test_cli.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,48 @@ def test_text_output_for_clean_workflow(self):
4040
self.assertEqual(code, 0)
4141
self.assertIn("OK:", stdout.getvalue())
4242

43+
def test_github_annotations_output_points_to_workflow_line(self):
44+
with tempfile.TemporaryDirectory() as tmp:
45+
workflow = Path(tmp) / "ci.yml"
46+
workflow.write_text(
47+
"steps:\n - uses: actions/checkout@main\n",
48+
encoding="utf-8",
49+
)
50+
stdout = io.StringIO()
51+
with redirect_stdout(stdout):
52+
code = main(
53+
[
54+
tmp,
55+
"--format",
56+
"github-annotations",
57+
"--fail-on",
58+
"never",
59+
]
60+
)
61+
62+
self.assertEqual(code, 0)
63+
self.assertEqual(
64+
stdout.getvalue().strip(),
65+
"::error file=ci.yml,line=2,title=floating-branch-ref::"
66+
"Action is pinned to a mutable branch ref. Fix: Replace the branch "
67+
"with a reviewed full commit SHA. uses: actions/checkout@main",
68+
)
69+
70+
def test_github_annotations_output_is_empty_for_clean_workflow(self):
71+
with tempfile.TemporaryDirectory() as tmp:
72+
workflow = Path(tmp) / "safe.yml"
73+
workflow.write_text(
74+
"steps:\n"
75+
" - uses: actions/checkout@0123456789abcdef0123456789abcdef01234567\n",
76+
encoding="utf-8",
77+
)
78+
stdout = io.StringIO()
79+
with redirect_stdout(stdout):
80+
code = main([tmp, "--format", "github-annotations"])
81+
82+
self.assertEqual(code, 0)
83+
self.assertEqual(stdout.getvalue(), "")
84+
4385

4486
if __name__ == "__main__":
4587
unittest.main()

0 commit comments

Comments
 (0)