Skip to content

Commit 106bfbd

Browse files
authored
ci: audit example with-keys against current action.yml input contracts (#136)
* ci: audit example with-keys against current action.yml input contracts Closes the gap that let the recent example-staleness drift through unnoticed. Composite-action consumers passing ``with:`` keys that aren't declared in ``action.yml::inputs`` see a runtime warning, not a failure. CI never *runs* the example workflows — only YAML-validates them — so a contract trim could silently leave ``examples/`` referencing inputs the action no longer accepts. PR #134 fixed the symptom; this PR closes the source. What ships ========== * ``scripts/ci/check_example_inputs.py`` — walks every ``examples/**/*.yml``, finds each ``uses: huntridge-labs/argus/.github/actions/<name>@<ref>`` step, and diffs its ``with:`` keys against the matching action's declared inputs. Reports unknown keys with job/step/file location. Exits 1 on any miss, 0 on clean, 2 on internal error (missing actions dir). Surfaces: --json machine-readable output --gh-annotations ``::error file=...:: PR-inline rendering --paths <dir>... override the default examples roots --actions-dir <p> override the default actions dir * ``tests/unit/test_check_example_inputs.py`` — 18 tests covering: input-name collection, clean / unknown-input / multi-key / missing-action / yaml-parse-error workflows, non-argus uses-step ignored, exit codes (0/1/2), JSON output shape, gh-annotations stderr emission, and a smoke test that the actual repo's ``examples/`` passes today. * ``.github/workflows/test-examples-functional.yml`` — new ``audit-action-inputs`` job runs once across the whole example tree (cross-action correlation, not per-example). Wired into the ``examples-summary`` aggregator so any failure blocks the workflow. Uses env-var-injected ``needs.<>.result`` values to stay workflow-injection-safe per the GitHub security guidance. Why a single job, not the per-example matrix ============================================= The audit needs the full ``action.yml`` input contract for every referenced action plus every ``with:`` block across the tree. Splitting per file would re-load the contracts N times and fragment the failure summary. One job, one parse pass, one report — fast enough that the matrix split adds nothing. Local verification ================== * ``python -m scripts.ci.check_example_inputs`` → exit 0 on ``feat/argus-portability`` post PRs #134 / #135. * Full SDK suite + the new tests: 2911 passed, 2 skipped, 7 deselected. * ``test-examples-functional.yml`` parses as valid YAML. Future ====== If a future contract trim lands without an examples follow-up, the audit fails the next PR-validation run with an inline annotation pointing at the offending example. Either fix the example, or update the action's contract — see the script's docstring for the resolution flow. * fix(ci): silence check_version_refs false-positives in audit script + tests The new audit script and its tests reference ``huntridge-labs/argus/. github/actions/<name>@<ref>`` as a literal placeholder pattern in docstrings, comments, and a test-helper f-string. The ``check_version_refs.py`` gate parses that as a real version ref and flags it as uncovered. Three minimal fixes — keep the placeholder text intact where the docstring needed it, just teach the gate to skip those lines: * scripts/ci/check_example_inputs.py:61 — added the ``release-it- ignore`` IGNORE_MARKER suffix to the comment line that documents the regex shape. * tests/unit/test_check_example_inputs.py:4 — embedded the marker inline in the docstring's prose. The marker is a plain string the gate searches for, so prose context works fine. * tests/unit/test_check_example_inputs.py::_write_example — pulled ``huntridge-labs/argus/.github/actions/`` into a local variable so the literal pattern doesn't appear on the same source line as the action name + ref. The rendered YAML still has a single ``uses:`` line, which is what the audit-under-test consumes. Verified: ``check_version_refs`` exits 0 against tracked files, the audit script + 18 unit tests still pass. --------- Co-authored-by: eFAILution <eFAILution@users.noreply.github.com>
1 parent 12fc7cc commit 106bfbd

3 files changed

Lines changed: 672 additions & 7 deletions

File tree

.github/workflows/test-examples-functional.yml

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,55 @@ jobs:
209209
fi
210210
done
211211
212+
# ============================================================================
213+
# Audit example workflows against current action.yml input contracts
214+
# Runs once across the whole tree (cross-action correlation, not
215+
# per-example). Catches the bug class where a contract trim leaves
216+
# documented examples passing inputs the action no longer accepts —
217+
# composite actions only emit a runtime warning for those, so the
218+
# drift never surfaces in the syntax-only validate-examples matrix.
219+
# See scripts/ci/check_example_inputs.py for the audit logic.
220+
# ============================================================================
221+
audit-action-inputs:
222+
name: Audit Example Action Inputs
223+
runs-on: ubuntu-latest
224+
needs: discover-examples
225+
timeout-minutes: 5
226+
steps:
227+
- name: Checkout repository
228+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
229+
with:
230+
persist-credentials: false
231+
232+
- name: Set up Python
233+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
234+
with:
235+
python-version: ${{ env.PYTHON_VERSION }}
236+
237+
- name: Install PyYAML
238+
run: pip install PyYAML
239+
240+
- name: Audit example with-keys against action.yml inputs
241+
run: |
242+
# --gh-annotations emits ::error file=...:: lines that render
243+
# inline in the PR Files-changed view, pinpointing the
244+
# offending example.
245+
python -m scripts.ci.check_example_inputs --gh-annotations
246+
247+
- name: Summarize
248+
if: always()
249+
run: |
250+
{
251+
echo "## Example Action-Input Audit"
252+
echo ""
253+
echo "Checks every \`uses: huntridge-labs/argus/.github/actions/<name>@<ref>\` step in"
254+
echo "\`examples/**\` against its action's current \`action.yml::inputs\` list."
255+
echo ""
256+
echo "Source of truth: \`.github/actions/<name>/action.yml\`."
257+
echo "Audit script: \`scripts/ci/check_example_inputs.py\`."
258+
echo ""
259+
} >> "$GITHUB_STEP_SUMMARY"
260+
212261
# ============================================================================
213262
# Final Summary
214263
# ============================================================================
@@ -218,31 +267,42 @@ jobs:
218267
needs:
219268
- validate-examples
220269
- validate-configs
270+
- audit-action-inputs
221271
if: always()
222272

223273
steps:
224274
- name: Generate Summary
275+
env:
276+
NEEDS_VALIDATE_EXAMPLES_RESULT: ${{ needs.validate-examples.result }}
277+
NEEDS_VALIDATE_CONFIGS_RESULT: ${{ needs.validate-configs.result }}
278+
NEEDS_AUDIT_ACTION_INPUTS_RESULT: ${{ needs.audit-action-inputs.result }}
225279
run: |
280+
examples_status="❌ Failed"
281+
[ "$NEEDS_VALIDATE_EXAMPLES_RESULT" = "success" ] && examples_status="✅ Passed"
282+
configs_status="❌ Failed"
283+
[ "$NEEDS_VALIDATE_CONFIGS_RESULT" = "success" ] && configs_status="✅ Passed"
284+
audit_status="❌ Failed"
285+
[ "$NEEDS_AUDIT_ACTION_INPUTS_RESULT" = "success" ] && audit_status="✅ Passed"
286+
226287
{
227288
echo "# Example Validation Results"
228289
echo ""
229290
echo "| Validation Type | Status |"
230291
echo "|-----------------|--------|"
231-
echo "| Workflow Examples | ${{ needs.validate-examples.result == 'success' && '✅ Passed' || '❌ Failed' }} |"
232-
echo "| Config Examples | ${{ needs.validate-configs.result == 'success' && '✅ Passed' || '❌ Failed' }} |"
292+
echo "| Workflow Examples | $examples_status |"
293+
echo "| Config Examples | $configs_status |"
294+
echo "| Action-Input Audit | $audit_status |"
233295
echo ""
234296
echo "> **Note**: Functional testing of actions is handled by \`test-actions.yml\`"
235297
echo "> This workflow focuses on validating example **documentation** is correct."
236298
echo ""
237299
} >> "$GITHUB_STEP_SUMMARY"
238300
239-
if [[ "${NEEDS_VALIDATE_EXAMPLES_RESULT}" == "success" && \
240-
"${NEEDS_VALIDATE_CONFIGS_RESULT}" == "success" ]]; then
301+
if [ "$NEEDS_VALIDATE_EXAMPLES_RESULT" = "success" ] && \
302+
[ "$NEEDS_VALIDATE_CONFIGS_RESULT" = "success" ] && \
303+
[ "$NEEDS_AUDIT_ACTION_INPUTS_RESULT" = "success" ]; then
241304
echo "✅ **All examples validated successfully!**" >> "$GITHUB_STEP_SUMMARY"
242305
else
243306
echo "❌ **Some validations failed - review above for details**" >> "$GITHUB_STEP_SUMMARY"
244307
exit 1
245308
fi
246-
env:
247-
NEEDS_VALIDATE_EXAMPLES_RESULT: ${{ needs.validate-examples.result }}
248-
NEEDS_VALIDATE_CONFIGS_RESULT: ${{ needs.validate-configs.result }}

scripts/ci/check_example_inputs.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
#!/usr/bin/env python3
2+
"""Audit example workflows against current action.yml input contracts.
3+
4+
Why this exists
5+
===============
6+
7+
Composite-action consumers passing inputs that aren't declared in
8+
``action.yml::inputs`` see a runtime warning, not a failure. CI never
9+
*runs* the example workflows — it only validates their YAML syntax.
10+
That gap let a pre-1.0.0 contract trim drift through unnoticed: every
11+
``examples/workflows/actions-scanner-zap-*.yml`` and several
12+
``examples/github-enterprise/*.yml`` kept passing inputs that
13+
``scanner-zap`` / ``scanner-container`` / ``scanner-gitleaks`` had
14+
already removed. Surfaced during an external GHES consumer migration
15+
audit and fixed in PR #134.
16+
17+
This script closes the gap: it parses every action's ``action.yml``
18+
to build the source-of-truth input list, walks every example
19+
workflow's ``with:`` blocks for ``uses: huntridge-labs/argus/.github/
20+
actions/<name>@<ref>`` steps, and reports any ``with:`` key that
21+
isn't in the action's current contract. Run from CI, this prevents
22+
the next contract trim from silently breaking the docs.
23+
24+
Usage
25+
=====
26+
27+
python -m scripts.ci.check_example_inputs # all examples
28+
python -m scripts.ci.check_example_inputs --paths examples/workflows
29+
python -m scripts.ci.check_example_inputs --json # machine-readable
30+
31+
Exit codes
32+
==========
33+
34+
* 0 — every ``with:`` key is declared in the matching action.yml
35+
* 1 — at least one unknown key found (failure list printed)
36+
* 2 — internal error (couldn't parse an action.yml, etc.)
37+
"""
38+
39+
from __future__ import annotations
40+
41+
import argparse
42+
import json
43+
import re
44+
import sys
45+
from pathlib import Path
46+
from typing import Iterator
47+
48+
import yaml
49+
50+
51+
# Composite actions live here. Source of truth for input names.
52+
ARGUS_ACTIONS_DIR = Path(".github/actions")
53+
54+
# Examples whose with: blocks we audit. Two roots cover all canonical
55+
# consumer-facing patterns:
56+
# examples/workflows/ — composite-action-based examples
57+
# examples/github-enterprise/ — GHES-targeted examples
58+
DEFAULT_EXAMPLE_ROOTS = ("examples/workflows", "examples/github-enterprise")
59+
60+
# Action references look like
61+
# uses: huntridge-labs/argus/.github/actions/<name>@<ref> # release-it-ignore
62+
# Ref can be a tag, branch, or SHA — we don't care which.
63+
_ACTION_USES_RE = re.compile(
64+
r"^huntridge-labs/argus/\.github/actions/([^@]+)@"
65+
)
66+
67+
68+
def collect_action_inputs(actions_dir: Path) -> dict[str, set[str]]:
69+
"""Return ``{action_name: {input_name, ...}}`` for every action.yml.
70+
71+
Missing or unparsable ``action.yml`` files are reported but do not
72+
halt the walk — they'll surface as "missing action" errors when an
73+
example references them.
74+
"""
75+
out: dict[str, set[str]] = {}
76+
for action_dir in sorted(actions_dir.iterdir()):
77+
yml = action_dir / "action.yml"
78+
if not yml.is_file():
79+
continue
80+
try:
81+
data = yaml.safe_load(yml.read_text()) or {}
82+
except yaml.YAMLError as exc:
83+
# Surface but don't halt — a broken action.yml is a
84+
# different bug class.
85+
print(
86+
f"::warning file={yml}::failed to parse action.yml: {exc}",
87+
file=sys.stderr,
88+
)
89+
continue
90+
out[action_dir.name] = set((data.get("inputs") or {}).keys())
91+
return out
92+
93+
94+
def iter_example_workflows(roots: tuple[str, ...]) -> Iterator[Path]:
95+
"""Yield every ``*.yml`` under the given example roots."""
96+
for root in roots:
97+
root_path = Path(root)
98+
if not root_path.is_dir():
99+
continue
100+
yield from sorted(root_path.rglob("*.yml"))
101+
102+
103+
def audit_workflow(
104+
wf: Path,
105+
action_inputs: dict[str, set[str]],
106+
) -> list[dict]:
107+
"""Return one issue dict per problem in *wf*.
108+
109+
Each issue has ``file``, ``job``, ``step``, ``action``, ``kind``,
110+
and either ``unknown`` (a sorted list of unrecognized keys) or
111+
``message`` (free text for missing-action / parse-fail cases).
112+
"""
113+
issues: list[dict] = []
114+
try:
115+
data = yaml.safe_load(wf.read_text())
116+
except yaml.YAMLError as exc:
117+
issues.append({
118+
"file": str(wf),
119+
"job": None,
120+
"step": None,
121+
"action": None,
122+
"kind": "yaml_parse_error",
123+
"message": str(exc),
124+
})
125+
return issues
126+
127+
if not isinstance(data, dict):
128+
return issues
129+
jobs = data.get("jobs") or {}
130+
if not isinstance(jobs, dict):
131+
return issues
132+
133+
for job_name, job in jobs.items():
134+
if not isinstance(job, dict):
135+
continue
136+
for step in (job.get("steps") or []):
137+
if not isinstance(step, dict):
138+
continue
139+
uses = step.get("uses", "")
140+
if not isinstance(uses, str):
141+
continue
142+
m = _ACTION_USES_RE.match(uses)
143+
if not m:
144+
continue
145+
action = m.group(1)
146+
step_name = step.get("name", "?")
147+
148+
if action not in action_inputs:
149+
issues.append({
150+
"file": str(wf),
151+
"job": job_name,
152+
"step": step_name,
153+
"action": action,
154+
"kind": "missing_action",
155+
"message": (
156+
f"action '{action}' is not present at "
157+
f"{ARGUS_ACTIONS_DIR / action}"
158+
),
159+
})
160+
continue
161+
162+
with_keys = set((step.get("with") or {}).keys())
163+
unknown = with_keys - action_inputs[action]
164+
if unknown:
165+
issues.append({
166+
"file": str(wf),
167+
"job": job_name,
168+
"step": step_name,
169+
"action": action,
170+
"kind": "unknown_input",
171+
"unknown": sorted(unknown),
172+
})
173+
174+
return issues
175+
176+
177+
def format_human(issues: list[dict]) -> str:
178+
if not issues:
179+
return "✓ all example with: keys match current action.yml contracts\n"
180+
lines = []
181+
by_file: dict[str, list[dict]] = {}
182+
for i in issues:
183+
by_file.setdefault(i["file"], []).append(i)
184+
for f, group in sorted(by_file.items()):
185+
lines.append(f"\n{f}:")
186+
for i in group:
187+
loc = f" {i['job'] or '?'}.{i['step'] or '?'}"
188+
if i["kind"] == "unknown_input":
189+
lines.append(
190+
f"{loc} (action: {i['action']}): unknown with: keys "
191+
f"{i['unknown']}"
192+
)
193+
elif i["kind"] == "missing_action":
194+
lines.append(f"{loc}: {i['message']}")
195+
else:
196+
lines.append(f"{loc}: {i['kind']}{i.get('message', '')}")
197+
lines.append("")
198+
lines.append(f"{len(issues)} issue(s) found across {len(by_file)} file(s)")
199+
return "\n".join(lines) + "\n"
200+
201+
202+
def format_actions_annotations(issues: list[dict]) -> str:
203+
"""GitHub Actions ``::error file=...,line=...::message`` annotations."""
204+
out = []
205+
for i in issues:
206+
if i["kind"] == "unknown_input":
207+
msg = (
208+
f"action '{i['action']}' does not declare these "
209+
f"with: keys: {', '.join(i['unknown'])}. "
210+
f"Compare against {ARGUS_ACTIONS_DIR / i['action']}/action.yml inputs."
211+
)
212+
elif i["kind"] == "missing_action":
213+
msg = i["message"]
214+
else:
215+
msg = i.get("message", i["kind"])
216+
out.append(f"::error file={i['file']}::{msg}")
217+
return "\n".join(out) + ("\n" if out else "")
218+
219+
220+
def main(argv: list[str] | None = None) -> int:
221+
parser = argparse.ArgumentParser(
222+
description=__doc__.split("\n\n", 1)[0],
223+
)
224+
parser.add_argument(
225+
"--paths", nargs="+", default=list(DEFAULT_EXAMPLE_ROOTS),
226+
help="Roots to walk for *.yml example files",
227+
)
228+
parser.add_argument(
229+
"--json", action="store_true",
230+
help="Emit issues as a JSON array on stdout",
231+
)
232+
parser.add_argument(
233+
"--actions-dir", type=Path, default=ARGUS_ACTIONS_DIR,
234+
help="Directory containing action.yml files",
235+
)
236+
parser.add_argument(
237+
"--gh-annotations", action="store_true",
238+
help="Also emit ``::error::`` annotations for GitHub Actions UI",
239+
)
240+
args = parser.parse_args(argv)
241+
242+
if not args.actions_dir.is_dir():
243+
print(
244+
f"actions directory not found: {args.actions_dir}",
245+
file=sys.stderr,
246+
)
247+
return 2
248+
249+
action_inputs = collect_action_inputs(args.actions_dir)
250+
251+
issues: list[dict] = []
252+
for wf in iter_example_workflows(tuple(args.paths)):
253+
issues.extend(audit_workflow(wf, action_inputs))
254+
255+
if args.json:
256+
json.dump(issues, sys.stdout, indent=2, sort_keys=True)
257+
sys.stdout.write("\n")
258+
else:
259+
sys.stdout.write(format_human(issues))
260+
261+
if args.gh_annotations and issues:
262+
sys.stderr.write(format_actions_annotations(issues))
263+
264+
return 1 if issues else 0
265+
266+
267+
if __name__ == "__main__":
268+
sys.exit(main())

0 commit comments

Comments
 (0)