Skip to content

Commit 5f12b06

Browse files
Merge pull request #43 from amd/dholanda/individual_testing
Improve validation mechanism
2 parents 94d71fa + 2b828e1 commit 5f12b06

2 files changed

Lines changed: 155 additions & 14 deletions

File tree

.github/workflows/validate.yml

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,50 @@ on:
1515
workflow_dispatch:
1616

1717
jobs:
18-
validate:
19-
name: Validate skills and plugin manifests
18+
# Enumerate the skills so the validation job can fan out over them with a
19+
# matrix. Running each skill in its own job (with fail-fast disabled) means
20+
# one broken skill shows up as a single red check instead of failing the
21+
# whole suite and hiding the status of every other skill.
22+
discover-skills:
23+
name: Discover skills
24+
runs-on: ubuntu-latest
25+
outputs:
26+
skills: ${{ steps.discover.outputs.skills }}
27+
steps:
28+
- name: Check out repository
29+
uses: actions/checkout@v4
30+
31+
- name: Set up uv
32+
uses: astral-sh/setup-uv@v7
33+
34+
- name: List skills
35+
id: discover
36+
run: echo "skills=$(uv run scripts/validate_skills.py --list)" >> "$GITHUB_OUTPUT"
37+
38+
validate-skill:
39+
name: Validate skill
40+
needs: discover-skills
41+
runs-on: ubuntu-latest
42+
strategy:
43+
# Don't cancel the other skills when one fails; we want to see every
44+
# skill's status in a single run.
45+
fail-fast: false
46+
matrix:
47+
skill: ${{ fromJson(needs.discover-skills.outputs.skills) }}
48+
steps:
49+
- name: Check out repository
50+
uses: actions/checkout@v4
51+
52+
- name: Set up uv
53+
uses: astral-sh/setup-uv@v7
54+
55+
- name: Validate skill
56+
run: uv run scripts/validate_skills.py --skill "${{ matrix.skill }}"
57+
58+
# Repo-wide checks that aren't tied to a single skill: the generated plugin
59+
# manifests and internal markdown references.
60+
validate-manifests:
61+
name: Validate plugin manifests
2062
runs-on: ubuntu-latest
2163
steps:
2264
- name: Check out repository
@@ -25,8 +67,11 @@ jobs:
2567
- name: Set up uv
2668
uses: astral-sh/setup-uv@v7
2769

28-
- name: Validate skills and generated manifests
29-
run: ./scripts/check.sh
70+
- name: Validate marketplace manifest
71+
run: uv run scripts/validate_skills.py --marketplace-only
72+
73+
- name: Validate generated Cursor manifest
74+
run: uv run scripts/generate_cursor_plugin.py --check
3075

3176
# Deterministic, offline-only reference check: relative paths and
3277
# heading anchors. External URLs are intentionally not checked here
@@ -37,3 +82,25 @@ jobs:
3782
with:
3883
args: --config .github/lychee.toml --offline --include-fragments --no-progress "./**/*.md"
3984
fail: true
85+
86+
# Single gate that aggregates the per-skill matrix and the repo-wide manifest
87+
# checks. Branch protection can require just this one check: it only passes
88+
# when every skill validated and the manifest job succeeded. Because matrix
89+
# jobs always succeed individually under `fail-fast: false`, we inspect the
90+
# job results explicitly rather than relying on `needs` short-circuiting.
91+
validate:
92+
name: Validate skills and plugin manifests
93+
needs: [validate-skill, validate-manifests]
94+
if: always()
95+
runs-on: ubuntu-latest
96+
steps:
97+
- name: Verify all validation jobs passed
98+
run: |
99+
echo "validate-skill result: ${{ needs.validate-skill.result }}"
100+
echo "validate-manifests result: ${{ needs.validate-manifests.result }}"
101+
if [ "${{ needs.validate-skill.result }}" != "success" ] || \
102+
[ "${{ needs.validate-manifests.result }}" != "success" ]; then
103+
echo "One or more validation jobs failed." >&2
104+
exit 1
105+
fi
106+
echo "All skill and manifest validations passed."

scripts/validate_skills.py

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,18 @@
2020
2121
Run from the repo root:
2222
23-
./scripts/check.sh # used by CI; thin wrapper
24-
uv run scripts/validate_skills.py # ad-hoc
23+
./scripts/check.sh # used locally; thin wrapper
24+
uv run scripts/validate_skills.py # validate every skill + manifest
2525
uv run scripts/validate_skills.py --skills-dir skills
26+
uv run scripts/validate_skills.py --list # print skill names as JSON
27+
uv run scripts/validate_skills.py --skill rocm-doctor # one skill only
28+
uv run scripts/validate_skills.py --marketplace-only # manifest only
2629
27-
Exits non-zero if any skill fails validation.
30+
The `--list` / `--skill` options let CI validate each skill in its own job
31+
(see .github/workflows/validate.yml) so a single bad skill doesn't mask the
32+
status of the others.
33+
34+
Exits non-zero if any validated skill (or the marketplace check) fails.
2835
"""
2936

3037
from __future__ import annotations
@@ -227,6 +234,53 @@ def validate_claude_marketplace(skill_dirs: list[Path]) -> list[str]:
227234
return errors
228235

229236

237+
def _print_report(report: SkillReport) -> int:
238+
"""Print a single skill report and return its error count."""
239+
status = "OK " if not report.errors else "FAIL"
240+
print(f"[{status}] {report.skill}")
241+
for err in report.errors:
242+
print(f" {err}")
243+
return len(report.errors)
244+
245+
246+
def list_skills(skills_dir: Path) -> int:
247+
"""Print discovered skill names as a compact JSON array (for CI matrices)."""
248+
skills = discover_skills(skills_dir)
249+
if not skills:
250+
print(f"No skills found under {skills_dir}", file=sys.stderr)
251+
return 1
252+
print(json.dumps([p.name for p in skills], separators=(",", ":")))
253+
return 0
254+
255+
256+
def run_single(skills_dir: Path, name: str) -> int:
257+
"""Validate a single skill directory by name (no marketplace cross-check)."""
258+
skill_dir = skills_dir / name
259+
if not skill_dir.is_dir():
260+
print(f"No such skill directory: {skill_dir}", file=sys.stderr)
261+
return 1
262+
263+
errors = _print_report(validate_skill(skill_dir))
264+
print(f"\nSummary: {errors} error(s) in skill `{name}`")
265+
return 0 if errors == 0 else 1
266+
267+
268+
def run_marketplace(skills_dir: Path) -> int:
269+
"""Validate only that marketplace.json is in sync with skills on disk."""
270+
skills = discover_skills(skills_dir)
271+
if not skills:
272+
print(f"No skills found under {skills_dir}", file=sys.stderr)
273+
return 1
274+
275+
marketplace_errors = validate_claude_marketplace(skills)
276+
status = "OK " if not marketplace_errors else "FAIL"
277+
print(f"[{status}] .claude-plugin/marketplace.json")
278+
for err in marketplace_errors:
279+
print(f" {err}")
280+
print(f"\nSummary: {len(marketplace_errors)} error(s) in marketplace manifest")
281+
return 0 if not marketplace_errors else 1
282+
283+
230284
def run(skills_dir: Path) -> int:
231285
skills = discover_skills(skills_dir)
232286
if not skills:
@@ -236,12 +290,7 @@ def run(skills_dir: Path) -> int:
236290
print(f"Validating {len(skills)} skill(s) in {skills_dir}\n")
237291
total_errors = 0
238292
for skill_dir in skills:
239-
report = validate_skill(skill_dir)
240-
status = "OK " if not report.errors else "FAIL"
241-
print(f"[{status}] {report.skill}")
242-
for err in report.errors:
243-
print(f" {err}")
244-
total_errors += len(report.errors)
293+
total_errors += _print_report(validate_skill(skill_dir))
245294

246295
marketplace_errors = validate_claude_marketplace(skills)
247296
marketplace_status = "OK " if not marketplace_errors else "FAIL"
@@ -264,8 +313,33 @@ def main(argv: list[str] | None = None) -> int:
264313
default=DEFAULT_SKILLS_DIR,
265314
help=f"Directory containing skill folders (default: {DEFAULT_SKILLS_DIR}).",
266315
)
316+
group = parser.add_mutually_exclusive_group()
317+
group.add_argument(
318+
"--list",
319+
action="store_true",
320+
help="Print discovered skill names as a JSON array and exit.",
321+
)
322+
group.add_argument(
323+
"--skill",
324+
metavar="NAME",
325+
help="Validate only the named skill directory (skips the marketplace "
326+
"cross-check, which is repo-wide).",
327+
)
328+
group.add_argument(
329+
"--marketplace-only",
330+
action="store_true",
331+
help="Only validate that marketplace.json is in sync with skills/.",
332+
)
267333
args = parser.parse_args(argv)
268-
return run(args.skills_dir.resolve())
334+
skills_dir = args.skills_dir.resolve()
335+
336+
if args.list:
337+
return list_skills(skills_dir)
338+
if args.skill:
339+
return run_single(skills_dir, args.skill)
340+
if args.marketplace_only:
341+
return run_marketplace(skills_dir)
342+
return run(skills_dir)
269343

270344

271345
if __name__ == "__main__":

0 commit comments

Comments
 (0)