name: Label ready-to-merge skill listings on: pull_request_target: types: [opened, synchronize, reopened, edited] permissions: contents: read pull-requests: write jobs: validate: runs-on: ubuntu-latest steps: - name: Fetch base README env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }} BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | gh api "repos/$BASE_REPO/contents/README.md?ref=$BASE_SHA" \ -H "Accept: application/vnd.github.raw" > base.md - name: Fetch head README env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | gh api "repos/$HEAD_REPO/contents/README.md?ref=$HEAD_SHA" \ -H "Accept: application/vnd.github.raw" > head.md - name: List changed files env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} PR: ${{ github.event.pull_request.number }} run: | gh api "repos/$REPO/pulls/$PR/files" --paginate -q '.[].filename' > changed.txt cat changed.txt - name: Validate PR id: validate run: | node <<'EOF' const fs = require('fs'); const base = fs.readFileSync('base.md', 'utf8'); const head = fs.readFileSync('head.md', 'utf8'); const changed = fs.readFileSync('changed.txt', 'utf8') .split('\n').map(s => s.trim()).filter(Boolean); const fail = (msg) => { console.error('FAIL:', msg); process.exit(1); }; // 1. Only README.md changed if (changed.length === 0) fail('no changed files reported'); for (const f of changed) { if (f !== 'README.md') fail(`disallowed file changed: ${f}`); } // 2. Locate Skills <-> Getting Started bounds in both base and head const bounds = (text) => { const lines = text.split('\n'); let start = -1, end = -1; for (let i = 0; i < lines.length; i++) { if (start === -1 && /^##\s+Skills\s*$/.test(lines[i])) start = i; else if (start !== -1 && /^##\s+Getting Started\s*$/.test(lines[i])) { end = i; break; } } if (start === -1 || end === -1) fail('could not locate Skills / Getting Started markers'); return { lines, start, end }; }; const b = bounds(base); const h = bounds(head); // 3. All edits must be within the Skills..Getting Started window. // Compare lines outside the window — they must be identical. const outside = (o) => [ o.lines.slice(0, o.start).join('\n'), o.lines.slice(o.end).join('\n'), ]; const [bPre, bPost] = outside(b); const [hPre, hPost] = outside(h); if (bPre !== hPre) fail('changes detected before the Skills section'); if (bPost !== hPost) fail('changes detected after the Getting Started section'); // 4. Diff inside the window — find added bullet lines. const bInside = b.lines.slice(b.start, b.end); const hInside = h.lines.slice(h.start, h.end); const bSet = new Set(bInside); const addedLines = hInside.filter(l => !bSet.has(l)); const bulletRe = /^\s*-\s+\[([^\]]+)\]\(([^)]+)\)/; const addedBullets = addedLines .map(l => ({ line: l, m: l.match(bulletRe) })) .filter(x => x.m) .map(x => ({ line: x.line, name: x.m[1], url: x.m[2] })); if (addedBullets.length === 0) fail('no new skill bullet detected in PR'); // 5. Every added bullet must link to an external repo (https, not our own host). for (const b of addedBullets) { if (!/^https?:\/\//i.test(b.url)) { fail(`bullet "${b.name}" does not link to an external URL: ${b.url}`); } try { const u = new URL(b.url); const host = u.hostname.toLowerCase(); if (host.endsWith('composio.dev') || host.endsWith('anthropic.com')) { fail(`bullet "${b.name}" links to internal host ${host}`); } } catch { fail(`bullet "${b.name}" has invalid URL`); } } // 6. No crypto/web3/blockchain/nft keywords anywhere in added lines. const blocked = /\b(crypto|cryptocurrency|web3|blockchain|nft|defi|token(?:omics)?|wallet\b|solana|ethereum|bitcoin)\b/i; for (const line of addedLines) { if (blocked.test(line)) fail(`blocked keyword in added line: ${line.trim()}`); } // 7. Each added bullet must sit alphabetically between its immediate // neighbors in its category (case-insensitive). Existing disorder // elsewhere in the category is grandfathered. const addedSet = new Set(addedBullets.map(b => b.line)); let category = null; const groups = new Map(); // category -> [{name, added}] for (const line of hInside) { const hMatch = line.match(/^###\s+(.+?)\s*$/); if (hMatch) { category = hMatch[1]; continue; } const m = line.match(bulletRe); if (m && category) { if (!groups.has(category)) groups.set(category, []); groups.get(category).push({ name: m[1], added: addedSet.has(line) }); } } const ci = (s) => s.toLowerCase(); for (const [cat, items] of groups) { for (let i = 0; i < items.length; i++) { if (!items[i].added) continue; const prev = items[i - 1]; const next = items[i + 1]; if (prev && ci(prev.name).localeCompare(ci(items[i].name)) > 0) { fail(`"${items[i].name}" placed after "${prev.name}" in category "${cat}" (out of alphabetical order)`); } if (next && ci(items[i].name).localeCompare(ci(next.name)) > 0) { fail(`"${items[i].name}" placed before "${next.name}" in category "${cat}" (out of alphabetical order)`); } } } console.log('OK: PR meets all criteria'); EOF - name: Add ready-to-merge label if: success() env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} PR: ${{ github.event.pull_request.number }} run: | gh pr edit "$PR" --repo "$REPO" --add-label "ready-to-merge"