name: Skill PR Validation on: pull_request: branches: [main] paths: - '.claude/skills/**' - 'skills-engine/**' jobs: # ── Job 1: Policy gate ──────────────────────────────────────────────── # Block PRs that add NEW skill files while also modifying source code. # Skill PRs should contain instructions for Claude, not raw source edits. policy-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Check for mixed skill + source changes run: | ADDED_SKILLS=$(git diff --name-only --diff-filter=A origin/main...HEAD \ | grep '^\\.claude/skills/' || true) CHANGED=$(git diff --name-only origin/main...HEAD) SOURCE=$(echo "$CHANGED" \ | grep -E '^src/|^container/|^package\.json|^package-lock\.json' || true) if [ -n "$ADDED_SKILLS" ] && [ -n "$SOURCE" ]; then echo "::error::PRs that add new skills should not modify source files." echo "" echo "New skill files:" echo "$ADDED_SKILLS" echo "" echo "Source files:" echo "$SOURCE" echo "" echo "Please split into separate PRs. See CONTRIBUTING.md." exit 1 fi - name: Comment on failure if: failure() uses: actions/github-script@v7 with: script: | github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: `This PR adds a skill while also modifying source code. A skill PR should not change source files—the skill should contain **instructions** for Claude to follow. If you're fixing a bug or simplifying code, please submit that as a separate PR. See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md) for details.` }) # ── Job 2: Detect which skills changed ──────────────────────────────── detect-changed: runs-on: ubuntu-latest outputs: skills: ${{ steps.detect.outputs.skills }} has_skills: ${{ steps.detect.outputs.has_skills }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Detect changed skills id: detect run: | CHANGED_SKILLS=$(git diff --name-only origin/main...HEAD \ | grep '^\\.claude/skills/' \ | sed 's|^\.claude/skills/||' \ | cut -d/ -f1 \ | sort -u \ | jq -R . | jq -s .) echo "skills=$CHANGED_SKILLS" >> "$GITHUB_OUTPUT" if [ "$CHANGED_SKILLS" = "[]" ]; then echo "has_skills=false" >> "$GITHUB_OUTPUT" else echo "has_skills=true" >> "$GITHUB_OUTPUT" fi echo "Changed skills: $CHANGED_SKILLS" # ── Job 3: Validate each changed skill in isolation ─────────────────── validate-skills: needs: detect-changed if: needs.detect-changed.outputs.has_skills == 'true' runs-on: ubuntu-latest strategy: fail-fast: false matrix: skill: ${{ fromJson(needs.detect-changed.outputs.skills) }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - name: Initialize skills system run: >- npx tsx -e "import { initNanoclawDir } from './skills-engine/index'; initNanoclawDir();" - name: Apply skill run: npx tsx scripts/apply-skill.ts ".claude/skills/${{ matrix.skill }}" - name: Typecheck after apply run: npx tsc --noEmit - name: Run skill tests run: | TEST_CMD=$(npx tsx -e " import { parse } from 'yaml'; import fs from 'fs'; const m = parse(fs.readFileSync('.claude/skills/${{ matrix.skill }}/manifest.yaml', 'utf-8')); if (m.test) console.log(m.test); ") if [ -n "$TEST_CMD" ]; then echo "Running: $TEST_CMD" eval "$TEST_CMD" else echo "No test command defined, skipping" fi # ── Summary gate for branch protection ──────────────────────────────── skill-validation-summary: needs: - policy-check - detect-changed - validate-skills if: always() runs-on: ubuntu-latest steps: - name: Check results run: | echo "policy-check: ${{ needs.policy-check.result }}" echo "validate-skills: ${{ needs.validate-skills.result }}" if [ "${{ needs.policy-check.result }}" = "failure" ]; then echo "::error::Policy check failed" exit 1 fi if [ "${{ needs.validate-skills.result }}" = "failure" ]; then echo "::error::Skill validation failed" exit 1 fi echo "All skill checks passed"