diff --git a/.github/workflows/squad-main-guard.yml b/.github/workflows/squad-main-guard.yml new file mode 100644 index 000000000..9399f9ec0 --- /dev/null +++ b/.github/workflows/squad-main-guard.yml @@ -0,0 +1,88 @@ +name: Squad Main Guard + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check for forbidden paths + uses: actions/github-script@v7 + with: + script: | + // Fetch all files changed in this PR (paginated) + const files = []; + let page = 1; + while (true) { + const resp = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + per_page: 100, + page + }); + files.push(...resp.data); + if (resp.data.length < 100) break; + page++; + } + + // Check each file against forbidden path rules + const forbidden = files + .map(f => f.filename) + .filter(f => { + // .ai-team/** — ALL team state files, zero exceptions + if (f === '.ai-team' || f.startsWith('.ai-team/')) return true; + // team-docs/** EXCEPT team-docs/blog/** — blog content is allowed + if (f.startsWith('team-docs/')) { + if (f.startsWith('team-docs/blog/')) return false; + return true; + } + return false; + }); + + if (forbidden.length === 0) { + core.info('✅ No forbidden paths found in PR — all clear.'); + return; + } + + // Build a clear, actionable error message + const lines = [ + '## 🚫 Forbidden files detected in PR to main', + '', + 'The following files must NOT be merged into `main`.', + '`.ai-team/` is runtime team state — it belongs on dev branches only.', + '`team-docs/` internal content should stay on dev (only `team-docs/blog/` is allowed on main).', + '', + '### Forbidden files found:', + '', + ...forbidden.map(f => `- \`${f}\``), + '', + '### How to fix:', + '', + '```bash', + '# Remove tracked .ai-team/ files (keeps local copies):', + 'git rm --cached -r .ai-team/', + '', + '# Remove tracked team-docs/ files (except blog/):', + 'git rm --cached -r team-docs/', + 'git checkout HEAD -- team-docs/blog/', + '', + '# Commit the removal and push:', + 'git commit -m "chore: remove forbidden paths from PR"', + 'git push', + '```', + '', + '> ⚠️ `.ai-team/` is in `.gitignore` but files previously force-added with `git add -f`', + '> remain tracked. `git rm --cached` untracks them without deleting your local copies.', + ]; + + core.setFailed(lines.join('\n')); diff --git a/.gitignore b/.gitignore index 1f5b08a4b..712e81311 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ Thumbs.db node_modules/ # Squad team state (created at runtime, never committed) +# ⚠️ NEVER force-add these files (git add -f .ai-team/). +# Previously force-added files remain tracked even with this rule. +# If tracked files leak in, remove them: git rm --cached -r .ai-team/ .ai-team/ # Squad Squad templates (internal, not distributed) diff --git a/templates/workflows/squad-main-guard.yml b/templates/workflows/squad-main-guard.yml new file mode 100644 index 000000000..9399f9ec0 --- /dev/null +++ b/templates/workflows/squad-main-guard.yml @@ -0,0 +1,88 @@ +name: Squad Main Guard + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check for forbidden paths + uses: actions/github-script@v7 + with: + script: | + // Fetch all files changed in this PR (paginated) + const files = []; + let page = 1; + while (true) { + const resp = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + per_page: 100, + page + }); + files.push(...resp.data); + if (resp.data.length < 100) break; + page++; + } + + // Check each file against forbidden path rules + const forbidden = files + .map(f => f.filename) + .filter(f => { + // .ai-team/** — ALL team state files, zero exceptions + if (f === '.ai-team' || f.startsWith('.ai-team/')) return true; + // team-docs/** EXCEPT team-docs/blog/** — blog content is allowed + if (f.startsWith('team-docs/')) { + if (f.startsWith('team-docs/blog/')) return false; + return true; + } + return false; + }); + + if (forbidden.length === 0) { + core.info('✅ No forbidden paths found in PR — all clear.'); + return; + } + + // Build a clear, actionable error message + const lines = [ + '## 🚫 Forbidden files detected in PR to main', + '', + 'The following files must NOT be merged into `main`.', + '`.ai-team/` is runtime team state — it belongs on dev branches only.', + '`team-docs/` internal content should stay on dev (only `team-docs/blog/` is allowed on main).', + '', + '### Forbidden files found:', + '', + ...forbidden.map(f => `- \`${f}\``), + '', + '### How to fix:', + '', + '```bash', + '# Remove tracked .ai-team/ files (keeps local copies):', + 'git rm --cached -r .ai-team/', + '', + '# Remove tracked team-docs/ files (except blog/):', + 'git rm --cached -r team-docs/', + 'git checkout HEAD -- team-docs/blog/', + '', + '# Commit the removal and push:', + 'git commit -m "chore: remove forbidden paths from PR"', + 'git push', + '```', + '', + '> ⚠️ `.ai-team/` is in `.gitignore` but files previously force-added with `git add -f`', + '> remain tracked. `git rm --cached` untracks them without deleting your local copies.', + ]; + + core.setFailed(lines.join('\n'));