Skip to content

Release: Windows path compatibility, issue templates, and dependency updates #20

Release: Windows path compatibility, issue templates, and dependency updates

Release: Windows path compatibility, issue templates, and dependency updates #20

name: Squad Protected Branch Guard
on:
pull_request:
branches: [main, preview, insider]
types: [opened, synchronize, reopened]
push:
branches: [main, preview, insider]
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 - handles both PR and push events
let files = [];
if (context.eventName === 'pull_request') {
// PR event: use pulls.listFiles API
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++;
}
} else if (context.eventName === 'push') {
// Push event: compare against base branch
const base = context.payload.before;
const head = context.payload.after;
// If this is not a force push and base exists, compare commits
if (base && base !== '0000000000000000000000000000000000000000') {
const comparison = await github.rest.repos.compareCommits({
owner: context.repo.owner,
repo: context.repo.repo,
base,
head
});
files = comparison.data.files || [];
} else {
// Force push or initial commit: list all files in the current tree
core.info('Force push detected or initial commit, checking tree state');
const { data: tree } = await github.rest.git.getTree({
owner: context.repo.owner,
repo: context.repo.repo,
tree_sha: head,
recursive: 'true'
});
files = tree.tree
.filter(item => item.type === 'blob')
.map(item => ({ filename: item.path, status: 'added' }));
}
}
// Check each file against forbidden path rules
// Allow removals — deleting forbidden files from protected branches is fine
const forbidden = files
.filter(f => f.status !== 'removed')
.map(f => f.filename)
.filter(f => {
// .ai-team/** and .squad/** — ALL team state files, zero exceptions
if (f === '.ai-team' || f.startsWith('.ai-team/') || f === '.squad' || f.startsWith('.squad/')) return true;
// .ai-team-templates/** — Squad's own templates, stay on dev
if (f === '.ai-team-templates' || f.startsWith('.ai-team-templates/')) return true;
// team-docs/** — ALL internal team docs, zero exceptions
if (f.startsWith('team-docs/')) return true;
// docs/proposals/** — internal design proposals, stay on dev
if (f.startsWith('docs/proposals/')) 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/` and `.squad/` are runtime team state — they belong on dev branches only.',
'`.ai-team-templates/` is Squad\'s internal planning — it belongs on dev branches only.',
'`team-docs/` is internal team content — it belongs on dev branches only.',
'`docs/proposals/` is internal design proposals — it belongs on dev branches only.',
'',
'### 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 .squad/ files (keeps local copies):',
'git rm --cached -r .squad/',
'',
'# Remove tracked team-docs/ files:',
'git rm --cached -r team-docs/',
'',
'# Commit the removal and push:',
'git commit -m "chore: remove forbidden paths from PR"',
'git push',
'```',
'',
'> ⚠️ `.ai-team/` and `.squad/` are committed on `dev` and feature branches by design.',
'> The guard workflow is the enforcement mechanism that keeps these files off `main` and `preview`.',
'> `git rm --cached` untracks them from this PR without deleting your local copies.',
];
core.setFailed(lines.join('\n'));