From ead08ebb3fcac06474ad17a796d66b8e49407329 Mon Sep 17 00:00:00 2001 From: Kane Zhu <843303+zxkane@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:27:53 +0800 Subject: [PATCH 1/2] ci: add marketplace + skill structure validation workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a GitHub-hosted (ubuntu-latest, public-repo-safe) check that runs a stdlib-only Python validator on every PR/push to main. It guards the exact failure classes this repo has hit: - marketplace.json is valid JSON with the expected shape - every plugin `source` dir exists; every `skills[]` entry resolves to a dir with a SKILL.md (catches a renamed source path with a stale entry) - every SKILL.md has frontmatter with name + description - every committed .claude/skills/* symlink resolves (catches dev symlinks left dangling after a plugin rename — cf. the aws-cdk -> aws-iac rename) - no orphan skills unregistered in any plugin The workflow uses no untrusted input and runs with contents:read only. Once green on main, the `validate-marketplace` check is set as a required status check on the branch protection rule. --- .github/workflows/validate.yml | 26 +++++ scripts/ci/validate_marketplace.py | 160 +++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 .github/workflows/validate.yml create mode 100644 scripts/ci/validate_marketplace.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..eb3f094 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,26 @@ +name: Validate Marketplace + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + validate: + name: validate-marketplace + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Validate marketplace + skills structure + run: python3 scripts/ci/validate_marketplace.py diff --git a/scripts/ci/validate_marketplace.py b/scripts/ci/validate_marketplace.py new file mode 100644 index 0000000..077af56 --- /dev/null +++ b/scripts/ci/validate_marketplace.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Validate the plugin marketplace structure. + +Checks (each a hard failure): + 1. .claude-plugin/marketplace.json is valid JSON with the expected shape. + 2. Every plugin's `source` directory exists. + 3. Every skill listed in a plugin's `skills[]` resolves to a directory + containing a SKILL.md (catches a renamed source path that forgot to + update a skill entry). + 4. Every SKILL.md (under any plugin) has YAML frontmatter with non-empty + `name` and `description`. + 5. Every committed symlink under .claude/skills/ resolves to an existing + directory (catches dev symlinks left dangling after a plugin rename). + 6. No orphan skills: every plugins/*/skills/*/SKILL.md is registered in + some plugin's skills[]. + +Pure stdlib; no third-party deps. Run from the repo root. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +MARKETPLACE = REPO / ".claude-plugin" / "marketplace.json" + +errors: list[str] = [] + + +def err(msg: str) -> None: + errors.append(msg) + + +def load_marketplace() -> dict | None: + if not MARKETPLACE.is_file(): + err(f"{MARKETPLACE.relative_to(REPO)} not found") + return None + try: + return json.loads(MARKETPLACE.read_text()) + except json.JSONDecodeError as e: + err(f"marketplace.json is not valid JSON: {e}") + return None + + +def frontmatter(skill_md: Path) -> dict[str, str]: + """Parse the leading YAML frontmatter block (name/description only).""" + text = skill_md.read_text(encoding="utf-8", errors="replace") + m = re.match(r"^---\n(.*?)\n---\n", text, re.DOTALL) + if not m: + return {} + fields: dict[str, str] = {} + for key in ("name", "description"): + km = re.search(rf"^{key}:\s*(.+?)\s*$", m.group(1), re.MULTILINE) + if km: + fields[key] = km.group(1) + return fields + + +def check_marketplace(data: dict) -> set[Path]: + """Validate plugins + skills. Returns the set of registered SKILL.md paths.""" + registered: set[Path] = set() + if "plugins" not in data or not isinstance(data["plugins"], list): + err("marketplace.json missing a `plugins` array") + return registered + + seen_names: set[str] = set() + for p in data["plugins"]: + name = p.get("name", "") + if name in seen_names: + err(f"duplicate plugin name: {name}") + seen_names.add(name) + + source = p.get("source") + if not source: + err(f"plugin {name}: missing `source`") + continue + src_dir = (REPO / source.lstrip("./")).resolve() + if not src_dir.is_dir(): + err(f"plugin {name}: source dir does not exist: {source}") + continue + + for skill_ref in p.get("skills", []): + skill_dir = (src_dir / skill_ref.lstrip("./")).resolve() + skill_md = skill_dir / "SKILL.md" + if not skill_dir.is_dir(): + err(f"plugin {name}: skill dir missing: {source}/{skill_ref}") + elif not skill_md.is_file(): + err(f"plugin {name}: no SKILL.md in {source}/{skill_ref}") + else: + registered.add(skill_md.resolve()) + return registered + + +def check_all_skill_frontmatter() -> set[Path]: + """Validate frontmatter of every SKILL.md; return the set found on disk.""" + found: set[Path] = set() + for skill_md in (REPO / "plugins").rglob("SKILL.md"): + found.add(skill_md.resolve()) + fm = frontmatter(skill_md) + rel = skill_md.relative_to(REPO) + if not fm.get("name"): + err(f"{rel}: frontmatter missing `name`") + if not fm.get("description"): + err(f"{rel}: frontmatter missing `description`") + return found + + +def check_dev_symlinks() -> None: + """Every .claude/skills/* symlink must resolve to an existing directory.""" + skills_dir = REPO / ".claude" / "skills" + if not skills_dir.is_dir(): + return # optional; absent is fine + for entry in sorted(skills_dir.iterdir()): + if not entry.is_symlink(): + continue + target = os.readlink(entry) + resolved = (entry.parent / target).resolve() + if not resolved.is_dir(): + err( + f".claude/skills/{entry.name}: dangling symlink -> {target} " + f"(resolves to {resolved}, which does not exist)" + ) + + +def check_orphans(registered: set[Path], found: set[Path]) -> None: + for skill_md in sorted(found - registered): + err( + f"orphan skill not registered in any plugin's skills[]: " + f"{skill_md.relative_to(REPO)}" + ) + + +def main() -> int: + data = load_marketplace() + registered: set[Path] = set() + if data is not None: + registered = check_marketplace(data) + found = check_all_skill_frontmatter() + check_dev_symlinks() + if data is not None: + check_orphans(registered, found) + + if errors: + print(f"✗ marketplace validation failed ({len(errors)} issue(s)):\n") + for e in errors: + print(f" - {e}") + return 1 + print("✓ marketplace validation passed") + plugin_count = len(data["plugins"]) if data is not None else 0 + print(f" plugins: {plugin_count}") + print(f" skills: {len(found)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 5ca6e9729b29e167bff7ef067d5c607f02fec6f9 Mon Sep 17 00:00:00 2001 From: Kane Zhu <843303+zxkane@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:36:01 +0800 Subject: [PATCH 2/2] fix(ci): harden marketplace validator per review Address Amazon Q review findings on the validator: - frontmatter(): wrap read_text in try/except OSError, report + treat as empty rather than crashing on an unreadable SKILL.md - check_all_skill_frontmatter(): guard against a missing plugins/ dir before rglob() instead of raising FileNotFoundError - check_dev_symlinks(): flag symlinks that resolve outside the repo (supply-chain hygiene) before the existence check - main(): use isinstance(plugins, list) for the summary count so a malformed (non-list) plugins value can't raise --- scripts/ci/validate_marketplace.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/scripts/ci/validate_marketplace.py b/scripts/ci/validate_marketplace.py index 077af56..a7fbef1 100644 --- a/scripts/ci/validate_marketplace.py +++ b/scripts/ci/validate_marketplace.py @@ -48,7 +48,14 @@ def load_marketplace() -> dict | None: def frontmatter(skill_md: Path) -> dict[str, str]: """Parse the leading YAML frontmatter block (name/description only).""" - text = skill_md.read_text(encoding="utf-8", errors="replace") + try: + text = skill_md.read_text(encoding="utf-8", errors="replace") + except OSError as e: + # Unreadable SKILL.md (missing, permissions, ...) -> report and treat as + # empty frontmatter, so the name/description checks flag it loudly + # instead of crashing the whole run. + err(f"{skill_md.relative_to(REPO)}: could not read SKILL.md ({e})") + return {} m = re.match(r"^---\n(.*?)\n---\n", text, re.DOTALL) if not m: return {} @@ -98,7 +105,11 @@ def check_marketplace(data: dict) -> set[Path]: def check_all_skill_frontmatter() -> set[Path]: """Validate frontmatter of every SKILL.md; return the set found on disk.""" found: set[Path] = set() - for skill_md in (REPO / "plugins").rglob("SKILL.md"): + plugins_dir = REPO / "plugins" + if not plugins_dir.is_dir(): + err("plugins/ directory not found") + return found + for skill_md in plugins_dir.rglob("SKILL.md"): found.add(skill_md.resolve()) fm = frontmatter(skill_md) rel = skill_md.relative_to(REPO) @@ -119,6 +130,16 @@ def check_dev_symlinks() -> None: continue target = os.readlink(entry) resolved = (entry.parent / target).resolve() + # A tracked dev symlink should point at an in-repo skill dir. Flag any + # that escape the repo (supply-chain hygiene) before the existence check. + try: + resolved.relative_to(REPO) + except ValueError: + err( + f".claude/skills/{entry.name}: symlink points outside the repo " + f"-> {target} (resolves to {resolved})" + ) + continue if not resolved.is_dir(): err( f".claude/skills/{entry.name}: dangling symlink -> {target} " @@ -150,7 +171,8 @@ def main() -> int: print(f" - {e}") return 1 print("✓ marketplace validation passed") - plugin_count = len(data["plugins"]) if data is not None else 0 + plugins = data.get("plugins") if data is not None else None + plugin_count = len(plugins) if isinstance(plugins, list) else 0 print(f" plugins: {plugin_count}") print(f" skills: {len(found)}") return 0