Merge pull request #248 from danfiedler-msft/danfiedler/pin-actions #21
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Sync Labels | |
| on: | |
| schedule: | |
| - cron: '0 8 * * 1' # Weekly on Monday at 08:00 UTC | |
| push: | |
| paths: | |
| - '.squad/team.md' | |
| - '.ai-team/team.md' | |
| - '.github/ISSUE_TEMPLATE/**' | |
| - '.github/workflows/issue-labels-sync.yml' | |
| workflow_dispatch: | |
| permissions: | |
| issues: write | |
| contents: read | |
| jobs: | |
| sync-labels: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 | |
| - name: Set up Node.js | |
| uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: '24' | |
| cache: 'npm' | |
| # Install production dependencies so the github-script step can `require('js-yaml')`. | |
| # github-script resolves bare module IDs from the workspace root (process.cwd()). | |
| # --ignore-scripts blocks dependency lifecycle scripts (supply-chain hardening); | |
| # our runtime deps are pure JS and need no install scripts. | |
| - name: Install dependencies | |
| run: npm ci --omit=dev --ignore-scripts | |
| - name: Parse roster and sync labels | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| let teamFile = '.squad/team.md'; | |
| if (!fs.existsSync(teamFile)) { | |
| teamFile = '.ai-team/team.md'; | |
| } | |
| const rosterExists = fs.existsSync(teamFile); | |
| if (!rosterExists) { | |
| core.info('No .squad/team.md or .ai-team/team.md found — skipping squad member labels (static labels still sync and issue templates are still validated)'); | |
| } | |
| const content = rosterExists ? fs.readFileSync(teamFile, 'utf8') : ''; | |
| const lines = content.split('\n'); | |
| // Parse the Members table for agent names | |
| const members = []; | |
| let inMembersTable = false; | |
| for (const line of lines) { | |
| if (line.match(/^##\s+(Members|Team Roster)/i)) { | |
| inMembersTable = true; | |
| continue; | |
| } | |
| if (inMembersTable && line.startsWith('## ')) { | |
| break; | |
| } | |
| if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { | |
| const cells = line.split('|').map(c => c.trim()).filter(Boolean); | |
| if (cells.length >= 2 && cells[0] !== 'Scribe') { | |
| members.push({ | |
| name: cells[0], | |
| role: cells[1] | |
| }); | |
| } | |
| } | |
| } | |
| core.info(`Found ${members.length} squad members: ${members.map(m => m.name).join(', ')}`); | |
| // Check if @copilot is on the team | |
| const hasCopilot = content.includes('🤖 Coding Agent'); | |
| // Define label color palette for squad labels | |
| const SQUAD_COLOR = '9B8FCC'; | |
| const MEMBER_COLOR = '9B8FCC'; | |
| const COPILOT_COLOR = '10b981'; | |
| // Define go: and release: labels (static) | |
| const GO_LABELS = [ | |
| { name: 'go:yes', color: '0E8A16', description: 'Ready to implement' }, | |
| { name: 'go:no', color: 'B60205', description: 'Not pursuing' }, | |
| { name: 'go:needs-research', color: 'FBCA04', description: 'Needs investigation' } | |
| ]; | |
| const RELEASE_LABELS = [ | |
| { name: 'release:v0.4.0', color: '6B8EB5', description: 'Targeted for v0.4.0' }, | |
| { name: 'release:v0.5.0', color: '6B8EB5', description: 'Targeted for v0.5.0' }, | |
| { name: 'release:v0.6.0', color: '8B7DB5', description: 'Targeted for v0.6.0' }, | |
| { name: 'release:v1.0.0', color: '8B7DB5', description: 'Targeted for v1.0.0' }, | |
| { name: 'release:backlog', color: 'D4E5F7', description: 'Not yet targeted' } | |
| ]; | |
| const TYPE_LABELS = [ | |
| { name: 'type:bug', color: 'FF0422', description: 'Something broken' }, | |
| { name: 'type:feature', color: 'DDD1F2', description: 'New capability' }, | |
| { name: 'type:enhancement', color: 'A2EEEF', description: 'Improvement to existing functionality' }, | |
| { name: 'type:spike', color: 'F2DDD4', description: 'Research/investigation — produces a plan, not code' }, | |
| { name: 'type:chore', color: 'D4E5F7', description: 'Maintenance, refactoring, cleanup' }, | |
| { name: 'type:epic', color: 'CC4455', description: 'Parent issue that decomposes into sub-issues' }, | |
| { name: 'type:documentation', color: '0075CA', description: 'Documentation issues or requests' }, | |
| { name: 'type:question', color: 'D876E3', description: 'Questions about usage or behavior' } | |
| ]; | |
| const CLOSE_LABELS = [ | |
| { name: 'close:fixed', color: '0E8A16', description: 'Fixed by a previous PR or release' }, | |
| { name: 'close:wont-fix', color: 'FFFFFF', description: 'Will not be addressed' }, | |
| { name: 'close:duplicate', color: 'CFD3D7', description: 'Duplicate of another issue' }, | |
| { name: 'close:question-answered', color: 'D876E3', description: 'Question has been answered' } | |
| ]; | |
| const EFFORT_LABELS = [ | |
| { name: 'effort:S', color: '0E8A16', description: 'Small effort (< 1 day)' }, | |
| { name: 'effort:M', color: 'FBCA04', description: 'Medium effort (1-3 days)' }, | |
| { name: 'effort:L', color: 'D93F0B', description: 'Large effort (3-10 days)' }, | |
| { name: 'effort:XL', color: 'B60205', description: 'Extra-large effort (> 10 days)' } | |
| ]; | |
| // High-signal labels — these MUST visually dominate all others | |
| const SIGNAL_LABELS = [ | |
| { name: 'feedback', color: '00E5FF', description: 'User feedback — high signal, needs attention' } | |
| ]; | |
| const PRIORITY_LABELS = [ | |
| { name: 'priority:p0', color: 'B60205', description: 'Blocking release' }, | |
| { name: 'priority:p1', color: 'D93F0B', description: 'This sprint' }, | |
| { name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' } | |
| ]; | |
| // Lifecycle labels managed by automated workflows | |
| const LIFECYCLE_LABELS = [ | |
| { name: 'stale', color: 'aaaaaa', description: 'No activity for 30+ days — will be closed if no further activity' }, | |
| { name: 'security-review', color: 'B60205', description: 'Requires security review — exempt from stale automation' } | |
| ]; | |
| function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } | |
| // Ensure the base "squad" triage label exists | |
| const labels = [ | |
| { name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' } | |
| ]; | |
| for (const member of members) { | |
| labels.push({ | |
| name: `squad:${slugify(member.name)}`, | |
| color: MEMBER_COLOR, | |
| description: `Assigned to ${member.name} (${member.role})` | |
| }); | |
| } | |
| // Add @copilot label if coding agent is on the team | |
| if (hasCopilot) { | |
| labels.push({ | |
| name: 'squad:copilot', | |
| color: COPILOT_COLOR, | |
| description: 'Assigned to @copilot (Coding Agent) for autonomous work' | |
| }); | |
| } | |
| // Add go:, release:, type:, close:, effort:, priority:, high-signal, and lifecycle labels | |
| labels.push(...GO_LABELS); | |
| labels.push(...RELEASE_LABELS); | |
| labels.push(...TYPE_LABELS); | |
| labels.push(...CLOSE_LABELS); | |
| labels.push(...EFFORT_LABELS); | |
| labels.push(...PRIORITY_LABELS); | |
| labels.push(...SIGNAL_LABELS); | |
| labels.push(...LIFECYCLE_LABELS); | |
| // Sync labels (create or update) | |
| for (const label of labels) { | |
| try { | |
| await github.rest.issues.getLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| name: label.name | |
| }); | |
| // Label exists — update it | |
| await github.rest.issues.updateLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| name: label.name, | |
| color: label.color, | |
| description: label.description | |
| }); | |
| core.info(`Updated label: ${label.name}`); | |
| } catch (err) { | |
| if (err.status === 404) { | |
| // Label doesn't exist — create it | |
| await github.rest.issues.createLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| name: label.name, | |
| color: label.color, | |
| description: label.description | |
| }); | |
| core.info(`Created label: ${label.name}`); | |
| } else { | |
| throw err; | |
| } | |
| } | |
| } | |
| core.info(`Label sync complete: ${labels.length} labels synced`); | |
| // Fail if any issue template references a label this workflow does not define. | |
| const yaml = require('js-yaml'); | |
| const definedLabelNames = new Set(labels.map(l => l.name.toLowerCase())); | |
| const templateDir = '.github/ISSUE_TEMPLATE'; | |
| const violations = []; | |
| let templateCount = 0; | |
| if (fs.existsSync(templateDir)) { | |
| const templateFiles = fs.readdirSync(templateDir).filter(f => { | |
| const lower = f.toLowerCase(); | |
| return /\.ya?ml$/.test(lower) && lower !== 'config.yml' && lower !== 'config.yaml'; | |
| }); | |
| templateCount = templateFiles.length; | |
| for (const file of templateFiles) { | |
| const templateContent = fs.readFileSync(`${templateDir}/${file}`, 'utf8'); | |
| let parsed; | |
| try { | |
| parsed = yaml.load(templateContent); | |
| } catch (err) { | |
| violations.push({ file, error: `could not be parsed as YAML: ${err.message}` }); | |
| continue; | |
| } | |
| // GitHub issue forms accept `labels` as a YAML sequence or a | |
| // comma-delimited string. | |
| const rawLabels = parsed && parsed.labels; | |
| let templateLabels = []; | |
| if (Array.isArray(rawLabels)) { | |
| templateLabels = rawLabels.map(l => String(l).trim()).filter(Boolean); | |
| } else if (typeof rawLabels === 'string') { | |
| templateLabels = rawLabels.split(',').map(l => l.trim()).filter(Boolean); | |
| } | |
| for (const label of templateLabels) { | |
| if (!definedLabelNames.has(label.toLowerCase())) { | |
| violations.push({ file, label }); | |
| } | |
| } | |
| } | |
| } | |
| if (violations.length > 0) { | |
| const details = violations | |
| .map(v => v.error | |
| ? ` - ${v.file}: ${v.error}` | |
| : ` - ${v.file}: "${v.label}" is not a defined label`) | |
| .join('\n'); | |
| core.setFailed( | |
| `Issue templates reference labels that are not defined by this workflow:\n${details}\n\n` + | |
| `Fix by either adding the label to this workflow's definitions or updating the ` + | |
| `template to use an existing label.` | |
| ); | |
| } else { | |
| core.info(`Validated ${templateCount} issue template(s): all referenced labels are defined`); | |
| } |