Skip to content

Merge pull request #248 from danfiedler-msft/danfiedler/pin-actions #21

Merge pull request #248 from danfiedler-msft/danfiedler/pin-actions

Merge pull request #248 from danfiedler-msft/danfiedler/pin-actions #21

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`);
}