Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/release-skill-zips.yaml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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:"
Expand Down Expand Up @@ -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!"
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
140 changes: 140 additions & 0 deletions scripts/package_skills.py
Original file line number Diff line number Diff line change
@@ -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())
Loading