-
Notifications
You must be signed in to change notification settings - Fork 0
feat(labels): add gate-label audit and seed referenced-but-unseeded labels #84
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| 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) |
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
| 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 }; | ||
| } |
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
| 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); | ||
| } | ||
| 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([]); | ||
| }); | ||
| }); | ||
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
| 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)); |
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
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This live test reads
.github/workflows, but I checked.github/workflows/self-test.yml:npm testonly triggers forscripts/**, 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*-labeldefault, 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 👍 / 👎.