Skip to content
Merged
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
117 changes: 117 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
name: CI

# Bespoke CI for a markdown-only skills library.
# There is no package.json, lockfile, build, or test in this repo — every skill
# is a declarative skills/<name>/SKILL.md file. So instead of the org's
# reusable ci-bun/ci-node workflows, we validate the skill contract directly:
# 1. Each skills/*/SKILL.md has YAML frontmatter that parses.
# 2. Frontmatter has both `name` and `description`.
# 3. Frontmatter `name` == the parent directory name.
# 4. The README.md skill table matches the skills present on disk (no drift).

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
validate-skills:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install PyYAML
run: pip install "PyYAML==6.0.2"

- name: Validate SKILL.md frontmatter and README table
run: |
python3 - <<'PY'
import os, re, sys, glob
import yaml

errors = []
skills_dir = "skills"

if not os.path.isdir(skills_dir):
print(f"::error::missing '{skills_dir}/' directory")
sys.exit(1)

# --- 1-3: per-skill frontmatter validation -----------------------
disk_skills = set()
skill_files = sorted(glob.glob(os.path.join(skills_dir, "*", "SKILL.md")))
if not skill_files:
errors.append("no skills/*/SKILL.md files found")

for path in skill_files:
dir_name = os.path.basename(os.path.dirname(path))
disk_skills.add(dir_name)
with open(path, encoding="utf-8") as fh:
text = fh.read()

# Frontmatter must be a leading --- ... --- block.
m = re.match(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", text, re.DOTALL)
if not m:
errors.append(f"{path}: missing or malformed YAML frontmatter (must start with '---')")
continue

try:
fm = yaml.safe_load(m.group(1))
except yaml.YAMLError as e:
errors.append(f"{path}: frontmatter does not parse as YAML: {e}")
continue

if not isinstance(fm, dict):
errors.append(f"{path}: frontmatter is not a YAML mapping")
continue

name = fm.get("name")
desc = fm.get("description")
if not name or not str(name).strip():
errors.append(f"{path}: frontmatter missing non-empty 'name'")
if not desc or not str(desc).strip():
errors.append(f"{path}: frontmatter missing non-empty 'description'")
if name and str(name).strip() != dir_name:
errors.append(
f"{path}: frontmatter name '{name}' != directory name '{dir_name}'"
)

# --- 4: README table drift vs disk ------------------------------
readme = "README.md"
if os.path.isfile(readme):
with open(readme, encoding="utf-8") as fh:
readme_text = fh.read()
# Match table rows linking to skills/<name>/SKILL.md
listed = set(re.findall(r"\]\(skills/([^/]+)/SKILL\.md\)", readme_text))

missing_from_readme = disk_skills - listed
missing_from_disk = listed - disk_skills
for s in sorted(missing_from_readme):
errors.append(f"README.md: skill '{s}' exists on disk but is not in the README table")
for s in sorted(missing_from_disk):
errors.append(f"README.md: skill '{s}' is listed in the README table but has no skills/{s}/SKILL.md")
else:
errors.append("README.md: not found (cannot verify skill table drift)")

if errors:
for e in errors:
print(f"::error::{e}")
print(f"\n{len(errors)} validation error(s).", file=sys.stderr)
sys.exit(1)

print(f"OK: {len(disk_skills)} skill(s) validated; README table in sync.")
PY

Loading