Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changelog/unreleased/83-gate-label-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
### Added

- Gate-label audit (`scripts/gate-labels.mjs` + `gate-labels.test.mjs`): fails if a
label declared as a `*-label`/`*-labels` workflow-input default is missing from
the seed set; reports unreferenced seed labels as informational only. (#83)
- Seed labels `ci:full`, `auto-triaged`, and `doc-orphan`, which reusable
workflows already reference but were never provisioned. (#83)

### Changed

- Moved the canonical label seed array out of `setup-repo.mjs` into
`scripts/labels.mjs` so the audit can import it without executing the CLI. (#83)
76 changes: 76 additions & 0 deletions scripts/gate-labels.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// gate-labels.mjs — audit that every GitHub label a reusable workflow applies or
// gates on is present in the canonical seed set (labels.mjs).
//
// Why: workflows declare their label dependency through `*-label` / `*-labels`
// inputs (e.g. repo-required-gate's `force-full-ci-label: ci:full`,
// changelog-fragment's `opt-out-label: no-changelog`). If such a label is never
// seeded, `gh ... --label X` and label-gated jobs fail on that repo. The audit
// FAILS on a referenced-but-unseeded label; it only REPORTS unreferenced seed
// labels (many are valid manual/triage labels with no gate that parses them).
// Source: forensic analysis of session 019eccc1 (F1) + owner refinement 4.

// Inputs whose name ends in -label(s) but whose value is NOT a label name.
const IGNORE_INPUTS = new Set(['sync-labels']); // labeler on/off toggle, not a label

// Matches BLOCK-style inputs only: the `*-label(s):` key on its own line, with the
// value on a following `default:` line. Inline/flow-style on one line (e.g.
// `force-full-ci-label: ci:full`) is intentionally NOT scanned — every live workflow
// input is block-style (locked by gate-labels.test.mjs). Extend this if a future
// workflow ever declares a gate label inline, or the audit could miss it.
const LABEL_INPUT_RE = /^(\s*)([a-z0-9][a-z0-9-]*-labels?):\s*$/i;
const DEFAULT_RE = /^\s*default:\s*(.+?)\s*$/;

/**
* Extract label names declared as defaults of `*-label` / `*-labels` workflow
* inputs. Singular inputs yield one name; plural inputs are comma-split.
* Skips the ignore set, booleans, empty defaults, and `${{ }}` expressions.
*
* @param {string} workflowText Raw YAML of a workflow file.
* @returns {string[]} referenced label names (may contain duplicates)
*/
export function extractLabelInputDefaults(workflowText) {
const lines = String(workflowText || '').split(/\r?\n/);
const found = [];

for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(LABEL_INPUT_RE);
if (!m) continue;
const [, indent, inputName] = m;
if (IGNORE_INPUTS.has(inputName)) continue;
const plural = inputName.endsWith('-labels');

for (let j = i + 1; j < lines.length; j++) {
const line = lines[j];
if (line.trim() === '') continue;
const lineIndent = (line.match(/^(\s*)/)[1] || '').length;
if (lineIndent <= indent.length) break; // left this input's block

const d = line.match(DEFAULT_RE);
if (!d) continue;
const val = d[1].replace(/^["']|["']$/g, '').trim();
if (!val || val === 'true' || val === 'false' || val.startsWith('${{')) break;
const names = plural ? val.split(',').map((s) => s.trim()).filter(Boolean) : [val];
found.push(...names);
break;
}
}

return found;
}

/**
* Compare the set of gate-referenced labels against the seed set.
*
* @param {Iterable<string>} referenced Labels referenced by gates.
* @param {Iterable<string>} seed Seeded label names.
* @returns {{missing: string[], unreferenced: string[]}}
* missing = referenced but NOT seeded (a real gap — fail).
* unreferenced = seeded but not gate-referenced (informational only).
*/
export function auditGateLabels(referenced, seed) {
const seedSet = seed instanceof Set ? seed : new Set(seed);
const refSet = referenced instanceof Set ? referenced : new Set(referenced);
const missing = [...refSet].filter((name) => !seedSet.has(name)).sort();
const unreferenced = [...seedSet].filter((name) => !refSet.has(name)).sort();
return { missing, unreferenced };
}
74 changes: 74 additions & 0 deletions scripts/gate-labels.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { LABEL_NAMES } from './labels.mjs';
import { auditGateLabels, extractLabelInputDefaults } from './gate-labels.mjs';

const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const workflowsDir = join(repoRoot, '.github', 'workflows');

function referencedGateLabels() {
const names = new Set();
for (const file of readdirSync(workflowsDir)) {
if (!/\.ya?ml$/.test(file)) continue;
const text = readFileSync(join(workflowsDir, file), 'utf8');
for (const name of extractLabelInputDefaults(text)) names.add(name);
Comment on lines +13 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run the gate-label audit on workflow-only PRs

This live test reads .github/workflows, but I checked .github/workflows/self-test.yml: npm test only triggers for scripts/**, package files, or the self-test workflow itself, while the reusable required gate's workflow-validation lane only runs actionlint. When a PR changes only a reusable workflow and adds or renames a *-label default, this audit will not run, so the exact referenced-but-unseeded label gap this change is meant to prevent can still merge. Please wire workflow-file changes into the self-test/required check that runs this test.

Useful? React with 👍 / 👎.

}
return names;
}

describe('gate-label audit (live workflows vs. seed set)', () => {
const referenced = referencedGateLabels();

it('statically collects the labels workflows depend on', () => {
// Sanity: the scanner finds the labels we know gates apply/gate on.
for (const name of ['ci:full', 'no-changelog', 'anomaly', 'doc-orphan', 'auto-triaged']) {
expect(referenced.has(name)).toBe(true);
}
});

it('seeds every gate-referenced label (referenced-but-unseeded is a hard failure)', () => {
const { missing } = auditGateLabels(referenced, LABEL_NAMES);
expect(missing).toEqual([]);
});

it('treats unreferenced seed labels as informational, not a failure', () => {
const { unreferenced } = auditGateLabels(referenced, LABEL_NAMES);
expect(Array.isArray(unreferenced)).toBe(true);
if (unreferenced.length > 0) {
// eslint-disable-next-line no-console
console.info(
`[gate-label audit] ${unreferenced.length} seed labels are not gate-referenced (informational): ${unreferenced.join(', ')}`,
);
}
});
});

describe('extractLabelInputDefaults', () => {
it('reads a singular *-label input default', () => {
const yml = ['inputs:', ' opt-out-label:', ' type: string', ' default: "no-changelog"'].join('\n');
expect(extractLabelInputDefaults(yml)).toEqual(['no-changelog']);
});

it('splits a plural *-labels input default on commas', () => {
const yml = [' exempt-pr-labels:', ' default: "pinned,wip,blocked"'].join('\n');
expect(extractLabelInputDefaults(yml)).toEqual(['pinned', 'wip', 'blocked']);
});

it('ignores the sync-labels toggle, booleans, and ${{ }} expressions', () => {
const yml = [
' sync-labels:',
' default: false',
' some-label:',
' default: ${{ inputs.x }}',
].join('\n');
expect(extractLabelInputDefaults(yml)).toEqual([]);
});

it('intentionally ignores inline (flow-style) label inputs (block-style only)', () => {
// Documents the LABEL_INPUT_RE scope: a single-line `key: value` is not scanned.
// All live workflow label inputs are block-style; revisit if that ever changes.
expect(extractLabelInputDefaults(' force-full-ci-label: ci:full')).toEqual([]);
});
});
66 changes: 66 additions & 0 deletions scripts/labels.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// labels.mjs — canonical ArchonVII label seed set.
//
// Applied by setup-repo.mjs and audited by gate-labels.test.mjs. Any label that a
// reusable workflow or script *applies* or *gates on* (see gate-labels.mjs) MUST
// appear here, or `gh ... --label X` and label-gated workflows fail on repos that
// were never seeded with that label. The gate-label audit enforces this.
//
// Source of the gate-required entries: the `*-label` input defaults in
// .github/workflows/*.yml (see gate-labels.mjs `extractLabelInputDefaults`).

export const LABELS = [
// Type
{ name: 'bug', color: 'D93F0B', description: 'Something is broken' },
{ name: 'enhancement', color: 'A2EEEF', description: 'New feature or capability' },
{ name: 'documentation', color: '0075CA', description: 'Docs gap, error, or improvement' },
{ name: 'chore', color: 'CFD3D7', description: 'Tech debt, refactor, cleanup' },
{ name: 'refactor', color: 'CFD3D7', description: 'Code restructure without behavior change' },
{ name: 'tests', color: '5319E7', description: 'Test-only changes' },
{ name: 'performance', color: 'FBCA04', description: 'Performance improvement' },
{ name: 'dependencies', color: '0366D6', description: 'Dependency bump or change' },
{ name: 'security', color: 'B60205', description: 'Security-relevant change' },
{ name: 'breaking', color: 'B60205', description: 'Breaking change (API, schema, behavior)' },

// Severity (used by anomaly-to-issue workflow)
{ name: 'severity:low', color: '0E8A16', description: 'Anomaly severity: low' },
{ name: 'severity:medium', color: 'FBCA04', description: 'Anomaly severity: medium' },
{ name: 'severity:high', color: 'D93F0B', description: 'Anomaly severity: high' },
{ name: 'severity:critical', color: 'B60205', description: 'Anomaly severity: critical' },

// Priority
{ name: 'priority:p0', color: 'B60205', description: 'Drop everything' },
{ name: 'priority:p1', color: 'D93F0B', description: 'Do soon' },
{ name: 'priority:p2', color: 'FBCA04', description: 'Normal' },
{ name: 'priority:p3', color: 'C5DEF5', description: 'Nice to have' },

// Effort (from the `open` skill — drives triage and PRD breakdown)
{ name: 'effort:s', color: 'C2E0C6', description: '< 1 hour' },
{ name: 'effort:m', color: 'BFE5BF', description: '~ half day' },
{ name: 'effort:l', color: 'FBCA04', description: '1–2 days' },
{ name: 'effort:xl', color: 'D93F0B', description: 'Multi-day' },

// Status
{ name: 'wip', color: 'FEF2C0', description: 'Work in progress; not ready for review' },
{ name: 'blocked', color: 'E11D21', description: 'Blocked on external dependency' },
{ name: 'stale', color: 'CFD3D7', description: 'Auto-applied by stale workflow' },
{ name: 'pinned', color: '5319E7', description: 'Exempt from stale/lock workflows' },
{ name: 'roadmap', color: '5319E7', description: 'Long-running roadmap tracking issue' },

// Workflow / release
{ name: 'no-changelog', color: 'EDEDED', description: 'Skip the CHANGELOG fragment requirement' },
{ name: 'anomaly', color: 'B60205', description: 'Auto-promoted from .anomalies/ file on merge' },
{ name: 'ignore-for-release', color: 'EDEDED', description: 'Exclude from auto-generated release notes' },
{ name: 'auto-merge', color: '0E8A16', description: 'Eligible for auto-merge once CI is green' },
// Gate-required labels surfaced by the gate-label audit (referenced-but-unseeded).
// Colors are cosmetic; the provenance is the referencing workflow named below.
{ name: 'ci:full', color: '5319E7', description: 'Force the full required-gate CI lanes regardless of changed paths' }, // force-full-ci-label default in repo-required-gate.yml
{ name: 'auto-triaged', color: 'C5DEF5', description: 'Applied by the anomaly auto-triage workflow' }, // auto-triage-label default in anomaly-triage.yml
{ name: 'doc-orphan', color: 'FBCA04', description: 'Durable doc missing from its index (doc-orphan detector)' }, // base-label default in doc-orphan-detector.yml

// PRD / breakdown (from the `open` skill)
{ name: 'prd', color: '5319E7', description: 'Parent PRD issue — broken into tracer-bullet sub-issues' },
{ name: 'tracer-bullet', color: 'BFD4F2', description: 'Thin vertical slice cutting through all layers' },
{ name: 'needs-triage', color: 'FEF2C0', description: 'Not yet triaged into a bucket' },
];

export const LABEL_NAMES = new Set(LABELS.map((label) => label.name));
53 changes: 4 additions & 49 deletions scripts/setup-repo.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { LABELS } from './labels.mjs';

const exec = promisify(execFile);

Expand Down Expand Up @@ -51,55 +52,9 @@ const NO_PROTECTION = flag('--no-protection');

// --- Standard label set ----------------------------------------------------

const LABELS = [
// Type
{ name: 'bug', color: 'D93F0B', description: 'Something is broken' },
{ name: 'enhancement', color: 'A2EEEF', description: 'New feature or capability' },
{ name: 'documentation', color: '0075CA', description: 'Docs gap, error, or improvement' },
{ name: 'chore', color: 'CFD3D7', description: 'Tech debt, refactor, cleanup' },
{ name: 'refactor', color: 'CFD3D7', description: 'Code restructure without behavior change' },
{ name: 'tests', color: '5319E7', description: 'Test-only changes' },
{ name: 'performance', color: 'FBCA04', description: 'Performance improvement' },
{ name: 'dependencies', color: '0366D6', description: 'Dependency bump or change' },
{ name: 'security', color: 'B60205', description: 'Security-relevant change' },
{ name: 'breaking', color: 'B60205', description: 'Breaking change (API, schema, behavior)' },

// Severity (used by anomaly-to-issue workflow)
{ name: 'severity:low', color: '0E8A16', description: 'Anomaly severity: low' },
{ name: 'severity:medium', color: 'FBCA04', description: 'Anomaly severity: medium' },
{ name: 'severity:high', color: 'D93F0B', description: 'Anomaly severity: high' },
{ name: 'severity:critical', color: 'B60205', description: 'Anomaly severity: critical' },

// Priority
{ name: 'priority:p0', color: 'B60205', description: 'Drop everything' },
{ name: 'priority:p1', color: 'D93F0B', description: 'Do soon' },
{ name: 'priority:p2', color: 'FBCA04', description: 'Normal' },
{ name: 'priority:p3', color: 'C5DEF5', description: 'Nice to have' },

// Effort (from the `open` skill — drives triage and PRD breakdown)
{ name: 'effort:s', color: 'C2E0C6', description: '< 1 hour' },
{ name: 'effort:m', color: 'BFE5BF', description: '~ half day' },
{ name: 'effort:l', color: 'FBCA04', description: '1–2 days' },
{ name: 'effort:xl', color: 'D93F0B', description: 'Multi-day' },

// Status
{ name: 'wip', color: 'FEF2C0', description: 'Work in progress; not ready for review' },
{ name: 'blocked', color: 'E11D21', description: 'Blocked on external dependency' },
{ name: 'stale', color: 'CFD3D7', description: 'Auto-applied by stale workflow' },
{ name: 'pinned', color: '5319E7', description: 'Exempt from stale/lock workflows' },
{ name: 'roadmap', color: '5319E7', description: 'Long-running roadmap tracking issue' },

// Workflow / release
{ name: 'no-changelog', color: 'EDEDED', description: 'Skip the CHANGELOG fragment requirement' },
{ name: 'anomaly', color: 'B60205', description: 'Auto-promoted from .anomalies/ file on merge' },
{ name: 'ignore-for-release', color: 'EDEDED', description: 'Exclude from auto-generated release notes' },
{ name: 'auto-merge', color: '0E8A16', description: 'Eligible for auto-merge once CI is green' },

// PRD / breakdown (from the `open` skill)
{ name: 'prd', color: '5319E7', description: 'Parent PRD issue — broken into tracer-bullet sub-issues' },
{ name: 'tracer-bullet', color: 'BFD4F2', description: 'Thin vertical slice cutting through all layers' },
{ name: 'needs-triage', color: 'FEF2C0', description: 'Not yet triaged into a bucket' },
];
// The canonical label seed set now lives in labels.mjs (imported above) so the
// gate-label audit (gate-labels.test.mjs) can verify it against the labels that
// reusable workflows actually apply/gate on, without importing this CLI script.

// --- gh wrapper ------------------------------------------------------------

Expand Down
Loading