From 9769248516ec0ade0fa77d486eff4a162f97e042 Mon Sep 17 00:00:00 2001 From: Jeff Cameron <32875696+jcameronjeff@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:03:55 -0400 Subject: [PATCH] ci: add Phase 1 CI workflow --- .github/workflows/ci.yml | 117 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0ae2c94 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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//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//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 +