diff --git a/.github/workflows/release-skill-zips.yaml b/.github/workflows/release-skill-zips.yaml new file mode 100644 index 0000000..2bcdecc --- /dev/null +++ b/.github/workflows/release-skill-zips.yaml @@ -0,0 +1,62 @@ +name: Package & Release Skill ZIPs + +on: + release: + types: [published] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + +jobs: + package-and-release: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + + - name: Install dependencies + run: | + source $HOME/.cargo/env + make install + + - name: Package skills + run: | + source $HOME/.cargo/env + uv run python scripts/package_skills.py + + - name: Update rolling latest release + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + if ! ls dist/*.zip 1>/dev/null 2>&1; then + echo "No ZIPs found in dist/, skipping release update" + exit 1 + fi + gh release delete latest --yes 2>/dev/null || true + git tag -f latest + git push origin latest --force + gh release create latest dist/*.zip --title "Latest Skill Packs" --notes "Auto-updated on every merge to main. Download the ZIP for your pack." --latest + + - name: Upload release assets + if: github.event_name == 'release' && github.event.release.tag_name != 'latest' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + TAG="${{ github.event.release.tag_name }}" + find dist -name '*.zip' | while read zip; do + gh release upload "$TAG" "$zip" --clobber + done diff --git a/Makefile b/Makefile index 82ce119..6e23ed7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install validate validate-structure validate-collection-schema validate-collection-compliance validate-skill-design validate-skill-design-changed validate-mcp-tools clean check-uv +.PHONY: help install validate validate-structure validate-collection-schema validate-collection-compliance validate-skill-design validate-skill-design-changed validate-mcp-tools package clean check-uv help: @echo "agentic-plugins" @@ -12,6 +12,7 @@ help: @echo " validate-skill-design - Validate all skills (use PACK=rh-sre for a specific pack)" @echo " validate-skill-design-changed - Validate only changed skills (staged + unstaged, for local dev)" @echo " validate-mcp-tools - Validate allowed-tools against live MCP servers (requires podman)" + @echo " package - Package skills into ZIPs (output: dist/)" @echo " clean - Remove generated files" @echo "" @echo "Requirements:" @@ -97,7 +98,10 @@ validate-mcp-tools: check-uv @uv run python scripts/validate_mcp_tools.py $(if $(PACK),$(PACK)) @echo "MCP tool validation complete!" +package: check-uv + @uv run python scripts/package_skills.py + clean: @echo "Cleaning generated files..." - @rm -rf .validate/ + @rm -rf .validate/ dist/ @echo "Cleaned!" diff --git a/README.md b/README.md index 907b73b..1c2dcfc 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,15 @@ See each pack's README for available skills and usage examples. The `eval/` directory contains skill evaluation reports (`report.json` + `report.md` per skill) generated by an external evaluation pipeline. +### Download Skill Packs + +Pre-packaged ZIP files are available on the [Releases page](https://github.com/RHEcosystemAppEng/agentic-plugins/releases). The `latest` release is auto-updated on every merge to main. Each ZIP contains all skills for a pack with documentation included. + +To use them: +1. Go to the [latest release](https://github.com/RHEcosystemAppEng/agentic-plugins/releases/tag/latest) +2. Download the ZIP for your pack (e.g., `ocp-admin.zip`) +3. Upload to your AI agent of choice (e.g., ChatGPT Custom GPT, Claude) + --- ## Prerequisites diff --git a/scripts/package_skills.py b/scripts/package_skills.py new file mode 100644 index 0000000..6117547 --- /dev/null +++ b/scripts/package_skills.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Package skill packs into self-contained ZIPs.""" + +import argparse +import logging +import os +import sys +import zipfile +from dataclasses import dataclass, field +from pathlib import Path + +logger = logging.getLogger(__name__) + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_PACK_EXCLUDE = {"scripts", "catalog", ".claude", ".github", ".lola", "docs", "eval"} + + +@dataclass +class PackageReport: + total_packs: int = 0 + total_skills: int = 0 + total_size_bytes: int = 0 + broken_symlinks: list[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + + +def discover_packs(root: Path) -> list[str]: + return sorted( + d.name for d in root.iterdir() + if d.is_dir() and d.name not in _PACK_EXCLUDE and not d.name.startswith(".") + and ((d / "AGENTS.md").exists() or (d / "skills").is_dir()) + ) + + +def discover_skills(pack_dir: Path) -> list[Path]: + skills_dir = pack_dir / "skills" + if not skills_dir.is_dir(): + return [] + return sorted( + d for d in skills_dir.iterdir() + if d.is_dir() and (d / "SKILL.md").exists() + ) + + +def create_pack_zip(pack_dir: Path, output_path: Path) -> tuple[int, int, list[str]]: + """Create a single ZIP containing all skills for a pack. Returns (skill_count, file_count, broken_symlinks).""" + skills = discover_skills(pack_dir) + if not skills: + return 0, 0, [] + + broken_symlinks: list[str] = [] + file_count = 0 + + output_path.parent.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + for skill_dir in skills: + skill_name = skill_dir.name + for file_path in sorted(skill_dir.rglob("*")): + if file_path.is_dir(): + continue + + arcname = f"{skill_name}/{file_path.relative_to(skill_dir)}" + + if file_path.is_symlink(): + resolved = file_path.resolve() + if not resolved.exists(): + logger.warning("Broken symlink: %s -> %s", file_path, os.readlink(file_path)) + broken_symlinks.append(str(file_path)) + continue + zf.write(resolved, arcname) + else: + zf.write(file_path, arcname) + + file_count += 1 + + return len(skills), file_count, broken_symlinks + + +def package_all( + root: Path, + output_dir: Path, + packs: list[str] | None = None, +) -> PackageReport: + report = PackageReport() + + all_packs = discover_packs(root) + target_packs = [p for p in all_packs if p in packs] if packs else all_packs + report.total_packs = len(target_packs) + + for pack_name in target_packs: + pack_dir = root / pack_name + zip_path = output_dir / f"{pack_name}.zip" + + try: + skill_count, file_count, broken = create_pack_zip(pack_dir, zip_path) + if skill_count == 0: + logger.info("Pack %s has no skills, skipping", pack_name) + continue + report.broken_symlinks.extend(broken) + report.total_skills += skill_count + zip_size = zip_path.stat().st_size + report.total_size_bytes += zip_size + logger.info("Packaged %s (%d skills, %d files, %d KB)", pack_name, skill_count, file_count, zip_size // 1024) + except Exception as e: + report.errors.append(f"{pack_name}: {e}") + logger.error("Failed to package %s: %s", pack_name, e) + + return report + + +def main() -> int: + parser = argparse.ArgumentParser(description="Package skill packs into self-contained ZIPs") + parser.add_argument("--output-dir", default="dist", help="Output directory (default: dist)") + parser.add_argument("--packs", nargs="+", help="Only package these packs (default: all)") + parser.add_argument("--root", default=str(_REPO_ROOT), help="Repository root (default: auto-detect)") + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(message)s") + + root = Path(args.root).resolve() + output_dir = Path(args.output_dir) + if not output_dir.is_absolute(): + output_dir = root / output_dir + + report = package_all(root=root, output_dir=output_dir, packs=args.packs) + + print(f"\nPackaging complete: {report.total_packs} packs, {report.total_skills} skills, {report.total_size_bytes // 1024} KB") + if report.broken_symlinks: + print(f" Warnings: {len(report.broken_symlinks)} broken symlinks skipped") + if report.errors: + print(f" Errors: {len(report.errors)}") + for err in report.errors: + print(f" - {err}") + + return 1 if report.errors else 0 + + +if __name__ == "__main__": + sys.exit(main())