From 4d9694117700286eaf3cf234f65851b82494ba8d Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Wed, 20 May 2026 20:56:53 -0500 Subject: [PATCH 1/3] feat(scripts): add classify-pr classifier with vitest fixtures (#8) Pure ES-module classifier that decides which lane a PR routes to: docs-only, workflow-only, policy-only, snapshot-refresh, code lanes, or forced-full. Mirrors the parse-evidence.mjs pattern (pure function, dynamic-import from the workflow caller). 40 vitest fixtures cover every lane plus the Iridescent-Church guardrails: snapshot-only requires EVERY file to match (#3), polyglot fails fast on empty path lists (#4), ci:full label override beats every other lane (#2), and stack=minimal+code preserves the legacy invariant. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/classify-pr.mjs | 524 +++++++++++++++++++++++++++++++++++ scripts/classify-pr.test.mjs | 451 ++++++++++++++++++++++++++++++ 2 files changed, 975 insertions(+) create mode 100644 scripts/classify-pr.mjs create mode 100644 scripts/classify-pr.test.mjs diff --git a/scripts/classify-pr.mjs b/scripts/classify-pr.mjs new file mode 100644 index 0000000..c7440cb --- /dev/null +++ b/scripts/classify-pr.mjs @@ -0,0 +1,524 @@ +// Pure ES-module classifier for repo-required-gate.yml lane routing. +// +// Contract (issue #8 / Iridescent-Church plan 2026-05-20): +// Given the changed-file list for a PR (or non-PR event), labels, and the +// caller-declared stack + path inputs, decide which lane fires and which +// downstream jobs should run. +// +// Lanes (one is chosen, by priority): +// 1. forced-full (label: ) — user override, runs everything +// 2. snapshot-refresh — every file matches snapshotPaths +// 3. docs-only — every file matches doc filter +// 4. workflow-only — only .github/workflows or .githooks +// 5. policy-only — only AGENTS.md / .agent/** +// 6. code (node) — stack=node + code/pkg touched +// 7. code (python) — stack=python + code/pkg touched +// 8. code (polyglot: node) — polyglot + only node paths +// 9. code (polyglot: python) — polyglot + only python paths +// 10. code (polyglot: node+python) — polyglot + both +// 11. pass-through — no files / non-PR with minimal stack +// +// This module is intentionally pure: it accepts already-fetched PR data via +// its function signature and performs no network or SDK calls. SDK access +// happens in the workflow caller (actions/github-script) which then passes +// the results into classifyPR(). +// +// Path matching for nodePaths / pythonPaths / snapshotPaths / docPrefixes: +// - One pattern per line. Blanks and `#` comments ignored. +// - Trailing `/**` or `/` → prefix match (e.g. `server/**` matches +// `server/foo.ts` and `server/sub/bar.ts`). +// - No wildcards → exact-file match (e.g. `package.json`). +// - Other glob features (mid-path `*`, negation) are intentionally NOT +// supported to avoid a minimatch dependency; reach for those only if a +// real consumer needs them. + +const DEFAULT_DOC_EXTENSIONS = + 'md|mdx|txt|png|jpg|jpeg|gif|svg|webp|bmp|ico|avif'; + +const DEFAULT_DOC_PREFIXES = ['.changelog/', 'docs/']; + +const DEFAULT_CODE_EXTENSIONS = + /\.(c|cc|cpp|cs|go|java|js|jsx|ts|tsx|mjs|cjs|py|rs|rb|php|swift|kt|kts|scala)$/i; + +const DEFAULT_PACKAGE_RE = + /(^|\/)(package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|pyproject\.toml|requirements(-dev)?\.txt|uv\.lock|poetry\.lock|Pipfile(\.lock)?|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum)$/; + +const VALID_STACKS = new Set(['node', 'python', 'minimal', 'polyglot']); + +/** + * @typedef {Object} ClassifyInput + * @property {string[]} files PR changed-file paths. Empty array for non-PR events. + * @property {string[]} [labels] PR label names (case-insensitive comparison). + * @property {string} stack 'node' | 'python' | 'minimal' | 'polyglot'. + * @property {string} [nodePaths] Newline-separated patterns; required when stack='polyglot' (unless pythonPaths set). + * @property {string} [pythonPaths] Newline-separated patterns; required when stack='polyglot' (unless nodePaths set). + * @property {string} [snapshotPaths] Newline-separated patterns. Empty disables the snapshot-refresh lane. + * @property {string} [snapshotTestCommand] Non-empty when consumer wants the snapshot-validation job to actually run a test. + * @property {string} [forceFullCiLabel] Label that forces full CI (default 'ci:full'). + * @property {string} [docExtensions] Pipe-separated extensions (no dots). + * @property {string} [docPrefixes] Newline-separated prefixes (e.g. 'docs/', '.changelog/'). + * @property {boolean} [isPullRequest] False for push events; classifier widens routing accordingly. + */ + +/** + * @typedef {Object} ClassifyOutput + * @property {boolean} ok false when there is a fatal config error. + * @property {string[]} errors Fatal config errors (workflow should fail-fast on these). + * @property {string} lane Lane label for UI summary and core.notice. + * @property {Object} outputs Boolean flags matching the workflow's `needs.detect.outputs.*`. + * @property {boolean} outputs.docsOnly + * @property {boolean} outputs.runCi Legacy aggregate; true if any language CI runs. + * @property {boolean} outputs.runNodeCi + * @property {boolean} outputs.runPythonCi + * @property {boolean} outputs.runDependencyReview + * @property {boolean} outputs.runWorkflowValidation + * @property {boolean} outputs.runPolicyValidation + * @property {boolean} outputs.snapshotOnly + * @property {boolean} outputs.runSnapshotValidation + * @property {boolean} outputs.forcedFull + * @property {string[]} jobsRequired Job ids required for this lane. + * @property {string[]} jobsSkipped Job ids deliberately skipped. + * @property {string} filesSummary First 30 paths joined with `\n` for the step summary. + */ + +/** + * @param {ClassifyInput} input + * @returns {ClassifyOutput} + */ +export function classifyPR(input) { + const { + files = [], + labels = [], + stack, + nodePaths = '', + pythonPaths = '', + snapshotPaths = '', + snapshotTestCommand = '', + forceFullCiLabel = 'ci:full', + docExtensions = DEFAULT_DOC_EXTENSIONS, + docPrefixes = DEFAULT_DOC_PREFIXES.join('\n'), + isPullRequest = true, + } = input; + + const errors = []; + + if (!VALID_STACKS.has(stack)) { + errors.push( + `Unknown stack "${stack}"; expected one of ${[...VALID_STACKS].join(', ')}.`, + ); + return failedResult(errors); + } + + const nodePatterns = parsePatterns(nodePaths); + const pythonPatterns = parsePatterns(pythonPaths); + const snapshotPatterns = parsePatterns(snapshotPaths); + const docPrefixList = parsePatterns(docPrefixes); + + // Guardrail #4: polyglot callers MUST declare at least one path list. + // (If they declared only nodePaths we still infer python paths as empty — + // that's a valid "all node, no python" polyglot.) + if ( + stack === 'polyglot' && + nodePatterns.length === 0 && + pythonPatterns.length === 0 + ) { + errors.push( + 'stack=polyglot requires at least one of node-paths or python-paths to be set; both were empty.', + ); + return failedResult(errors); + } + + const docExtRe = new RegExp(`\\.(${docExtensions})$`, 'i'); + const isDocFile = (p) => + docExtRe.test(p) || docPrefixList.some((pre) => matchPattern(p, pre)); + + const labelNames = (labels || []).map((l) => String(l).toLowerCase()); + const forcedFull = labelNames.includes( + String(forceFullCiLabel || '').toLowerCase(), + ); + + // -------- non-PR (push) events: run everything within the stack ---------- + if (!isPullRequest) { + const outputs = { + docsOnly: false, + runCi: stack !== 'minimal', + runNodeCi: stack === 'node' || (stack === 'polyglot' && nodePatterns.length > 0), + runPythonCi: stack === 'python' || (stack === 'polyglot' && pythonPatterns.length > 0), + runDependencyReview: false, // dep-review only meaningful on PR diff + runWorkflowValidation: true, + runPolicyValidation: true, + snapshotOnly: false, + runSnapshotValidation: false, + forcedFull: false, + }; + return { + ok: true, + errors, + lane: 'push-event (broad validation)', + outputs, + jobsRequired: requiredJobs(outputs, false), + jobsSkipped: skippedJobs(outputs, false), + filesSummary: '', + }; + } + + // -------- PR events -------------------------------------------------------- + const names = files.slice(); + + // Policy files share the `.md` extension with docs but route to the + // policy lane, not docs-only. + const isPolicyFile = (name) => + name === 'AGENTS.md' || + name === 'CLAUDE.md' || + name === 'GEMINI.md' || + name === '.agent/check-map.yml' || + name.startsWith('.agent/schema/') || + name.startsWith('.github/'); + + // Classify file footprint. + const docsOnly = + names.length > 0 && + names.every((name) => isDocFile(name) && !isPolicyFile(name)); + + const workflowOrHook = names.some( + (name) => + name.startsWith('.github/workflows/') || name.startsWith('.githooks/'), + ); + + const onlyWorkflowOrHook = + names.length > 0 && + names.every( + (name) => + name.startsWith('.github/workflows/') || + name.startsWith('.githooks/'), + ); + + const policy = names.some(isPolicyFile); + + const onlyPolicyNonWorkflow = + names.length > 0 && + names.every( + (name) => + (name === 'AGENTS.md' || + name === 'CLAUDE.md' || + name === 'GEMINI.md' || + name.startsWith('.agent/')) && + !name.startsWith('.github/workflows/') && + !name.startsWith('.githooks/'), + ); + + const packageTouched = names.some((name) => DEFAULT_PACKAGE_RE.test(name)); + + const codeTouched = names.some((name) => { + if ( + name.startsWith('.github/') || + name.startsWith('.githooks/') || + name.startsWith('.agent/') + ) + return false; + if (isDocFile(name)) return false; + return ( + DEFAULT_CODE_EXTENSIONS.test(name) || + /^(src|test|tests|lib|bin|scripts)\//.test(name) + ); + }); + + // Snapshot-only — Guardrail #3: EVERY file must match snapshot-paths. + const snapshotOnly = + snapshotPatterns.length > 0 && + names.length > 0 && + names.every((name) => + snapshotPatterns.some((pat) => matchPattern(name, pat)), + ); + + // Per-language touch (polyglot stack uses this; other stacks derive + // run-node-ci / run-python-ci from the legacy aggregate). + const nodeTouched = + nodePatterns.length > 0 && + names.some((name) => + nodePatterns.some((pat) => matchPattern(name, pat)), + ); + const pythonTouched = + pythonPatterns.length > 0 && + names.some((name) => + pythonPatterns.some((pat) => matchPattern(name, pat)), + ); + + // -------- forced-full override ------------------------------------------- + if (forcedFull) { + const outputs = { + docsOnly: false, + runCi: stack !== 'minimal', + runNodeCi: + stack === 'node' || + (stack === 'polyglot' && nodePatterns.length > 0), + runPythonCi: + stack === 'python' || + (stack === 'polyglot' && pythonPatterns.length > 0), + runDependencyReview: true, + runWorkflowValidation: true, + runPolicyValidation: true, + snapshotOnly: false, + runSnapshotValidation: false, + forcedFull: true, + }; + return { + ok: true, + errors, + lane: `forced-full (label: ${forceFullCiLabel})`, + outputs, + jobsRequired: requiredJobs(outputs, true), + jobsSkipped: skippedJobs(outputs, true), + filesSummary: filesSummaryOf(names), + }; + } + + // -------- snapshot-refresh ----------------------------------------------- + if (snapshotOnly) { + const outputs = { + docsOnly: false, + runCi: false, + runNodeCi: false, + runPythonCi: false, + runDependencyReview: false, + runWorkflowValidation: false, + runPolicyValidation: false, + snapshotOnly: true, + runSnapshotValidation: snapshotTestCommand.trim().length > 0, + forcedFull: false, + }; + return { + ok: true, + errors, + lane: 'snapshot-refresh', + outputs, + jobsRequired: requiredJobs(outputs, true), + jobsSkipped: skippedJobs(outputs, true), + filesSummary: filesSummaryOf(names), + }; + } + + // -------- docs-only ------------------------------------------------------ + if (docsOnly) { + const outputs = { + docsOnly: true, + runCi: false, + runNodeCi: false, + runPythonCi: false, + runDependencyReview: false, + runWorkflowValidation: false, + runPolicyValidation: false, + snapshotOnly: false, + runSnapshotValidation: false, + forcedFull: false, + }; + return { + ok: true, + errors, + lane: 'docs-only', + outputs, + jobsRequired: requiredJobs(outputs, true), + jobsSkipped: skippedJobs(outputs, true), + filesSummary: filesSummaryOf(names), + }; + } + + // -------- workflow-only -------------------------------------------------- + if (onlyWorkflowOrHook) { + const outputs = { + docsOnly: false, + runCi: false, + runNodeCi: false, + runPythonCi: false, + runDependencyReview: false, + runWorkflowValidation: true, + runPolicyValidation: false, + snapshotOnly: false, + runSnapshotValidation: false, + forcedFull: false, + }; + return { + ok: true, + errors, + lane: 'workflow-only', + outputs, + jobsRequired: requiredJobs(outputs, true), + jobsSkipped: skippedJobs(outputs, true), + filesSummary: filesSummaryOf(names), + }; + } + + // -------- policy-only ---------------------------------------------------- + if (onlyPolicyNonWorkflow) { + const outputs = { + docsOnly: false, + runCi: false, + runNodeCi: false, + runPythonCi: false, + runDependencyReview: false, + runWorkflowValidation: false, + runPolicyValidation: true, + snapshotOnly: false, + runSnapshotValidation: false, + forcedFull: false, + }; + return { + ok: true, + errors, + lane: 'policy-only', + outputs, + jobsRequired: requiredJobs(outputs, true), + jobsSkipped: skippedJobs(outputs, true), + filesSummary: filesSummaryOf(names), + }; + } + + // -------- code-touched paths --------------------------------------------- + // Preserves the original invariant: stack=minimal must not see code or + // package changes. The wizard / template chose the wrong stack if we hit + // this branch on a minimal repo. + if (stack === 'minimal' && (codeTouched || packageTouched)) { + errors.push( + 'stack=minimal but code or package files were touched; convert to stack=node, stack=python, or stack=polyglot.', + ); + return failedResult(errors); + } + + // Stack-dependent language-CI fan-out. + let runNodeCi = false; + let runPythonCi = false; + + if (stack === 'node') { + runNodeCi = packageTouched || codeTouched; + } else if (stack === 'python') { + runPythonCi = packageTouched || codeTouched; + } else if (stack === 'polyglot') { + runNodeCi = nodeTouched; + runPythonCi = pythonTouched; + // Polyglot fallback: if package files were touched but path predicates + // didn't match (e.g. root package.json + nothing else), run node CI by + // convention because every npm-workspaces consumer has a root manifest. + // Consumers that want stricter behavior should put package.json in + // node-paths explicitly (recommended in plan §1). + if (!runNodeCi && !runPythonCi && packageTouched) { + runNodeCi = nodePatterns.length > 0; + runPythonCi = nodePatterns.length === 0 && pythonPatterns.length > 0; + } + } + // stack=minimal → both stay false (decision job will error if code is + // touched on a minimal repo; that's intentional). + + const outputs = { + docsOnly: false, + runCi: runNodeCi || runPythonCi, + runNodeCi, + runPythonCi, + runDependencyReview: packageTouched, + runWorkflowValidation: workflowOrHook, + runPolicyValidation: policy, + snapshotOnly: false, + runSnapshotValidation: false, + forcedFull: false, + }; + + return { + ok: true, + errors, + lane: codeLaneLabel(stack, runNodeCi, runPythonCi), + outputs, + jobsRequired: requiredJobs(outputs, true), + jobsSkipped: skippedJobs(outputs, true), + filesSummary: filesSummaryOf(names), + }; +} + +function codeLaneLabel(stack, runNodeCi, runPythonCi) { + if (stack === 'polyglot') { + if (runNodeCi && runPythonCi) return 'code (polyglot: node+python)'; + if (runNodeCi) return 'code (polyglot: node)'; + if (runPythonCi) return 'code (polyglot: python)'; + return 'pass-through'; + } + if (stack === 'node' && runNodeCi) return 'code (node)'; + if (stack === 'python' && runPythonCi) return 'code (python)'; + if (stack === 'minimal') return 'pass-through (minimal)'; + return 'pass-through'; +} + +function requiredJobs(outputs, runPrContract) { + const jobs = ['detect']; + if (runPrContract) jobs.push('pr-contract'); + if (outputs.runWorkflowValidation) jobs.push('workflow-validation'); + if (outputs.runPolicyValidation) jobs.push('policy-validation'); + if (outputs.runDependencyReview) jobs.push('dependency-review'); + if (outputs.runNodeCi) jobs.push('node-ci'); + if (outputs.runPythonCi) jobs.push('python-ci'); + if (outputs.runSnapshotValidation) jobs.push('snapshot-validation'); + jobs.push('decision'); + return jobs; +} + +function skippedJobs(outputs, isPullRequest) { + const all = [ + 'workflow-validation', + 'policy-validation', + 'dependency-review', + 'node-ci', + 'python-ci', + 'snapshot-validation', + ]; + const required = new Set(requiredJobs(outputs, isPullRequest)); + return all.filter((j) => !required.has(j)); +} + +function filesSummaryOf(names) { + return names.slice(0, 30).join('\n'); +} + +function failedResult(errors) { + return { + ok: false, + errors, + lane: 'error', + outputs: { + docsOnly: false, + runCi: false, + runNodeCi: false, + runPythonCi: false, + runDependencyReview: false, + runWorkflowValidation: false, + runPolicyValidation: false, + snapshotOnly: false, + runSnapshotValidation: false, + forcedFull: false, + }, + jobsRequired: ['detect'], + jobsSkipped: [], + filesSummary: '', + }; +} + +// Parse a newline-separated pattern list. Strips comments (`#`) and blanks. +function parsePatterns(raw) { + if (!raw) return []; + return String(raw) + .split(/\r?\n/) + .map((s) => s.replace(/#.*$/, '').trim()) + .filter(Boolean); +} + +// Match a single path against a single pattern. +// `foo/bar/**` → prefix match `foo/bar/` +// `foo/` → prefix match `foo/` +// `foo.json` → exact match +function matchPattern(path, pattern) { + if (!pattern) return false; + if (pattern.endsWith('/**')) { + const prefix = pattern.slice(0, -2); // keep trailing '/' + return path === prefix.slice(0, -1) || path.startsWith(prefix); + } + if (pattern.endsWith('/')) { + return path.startsWith(pattern); + } + return path === pattern; +} diff --git a/scripts/classify-pr.test.mjs b/scripts/classify-pr.test.mjs new file mode 100644 index 0000000..1802870 --- /dev/null +++ b/scripts/classify-pr.test.mjs @@ -0,0 +1,451 @@ +import { describe, it, expect } from 'vitest'; +import { classifyPR } from './classify-pr.mjs'; + +// Convenience builder for the common-case input. +const input = (over = {}) => ({ + files: [], + labels: [], + stack: 'node', + isPullRequest: true, + ...over, +}); + +describe('classifyPR — backward-compat stack=node', () => { + it('1. code change in src/ → code (node), runs node-ci + decision', () => { + const r = classifyPR(input({ files: ['src/foo.ts'] })); + expect(r.ok).toBe(true); + expect(r.lane).toBe('code (node)'); + expect(r.outputs.runNodeCi).toBe(true); + expect(r.outputs.runPythonCi).toBe(false); + expect(r.outputs.runCi).toBe(true); + expect(r.outputs.runDependencyReview).toBe(false); + expect(r.jobsRequired).toContain('node-ci'); + expect(r.jobsRequired).toContain('decision'); + }); + + it('2. package.json bump → runDependencyReview true, node-ci on', () => { + const r = classifyPR(input({ files: ['package.json'] })); + expect(r.outputs.runDependencyReview).toBe(true); + expect(r.outputs.runNodeCi).toBe(true); + }); + + it('3. .changelog/ entry counts as docs-only', () => { + const r = classifyPR(input({ files: ['.changelog/unreleased/foo.md'] })); + expect(r.lane).toBe('docs-only'); + expect(r.outputs.docsOnly).toBe(true); + expect(r.outputs.runNodeCi).toBe(false); + }); +}); + +describe('classifyPR — docs-only lane', () => { + it('4. only .md + image → docs-only', () => { + const r = classifyPR(input({ files: ['README.md', 'assets/logo.png'] })); + expect(r.lane).toBe('docs-only'); + expect(r.outputs.docsOnly).toBe(true); + expect(r.jobsSkipped).toEqual( + expect.arrayContaining(['node-ci', 'python-ci', 'dependency-review']), + ); + }); + + it('5. docs/ prefix counts even on non-md extension via docExtensions only — but docs/foo.json should NOT be docs-only', () => { + // The legacy classifier uses docPrefixes for prefix-match; foo.json + // under docs/ matches the prefix and therefore is doc-like. + const r = classifyPR(input({ files: ['docs/spec.json'] })); + expect(r.lane).toBe('docs-only'); + }); +}); + +describe('classifyPR — workflow-only lane', () => { + it('6. only .github/workflows/foo.yml → workflow-only, validation on, policy off', () => { + const r = classifyPR(input({ files: ['.github/workflows/ci.yml'] })); + expect(r.lane).toBe('workflow-only'); + expect(r.outputs.runWorkflowValidation).toBe(true); + expect(r.outputs.runPolicyValidation).toBe(false); + expect(r.outputs.runNodeCi).toBe(false); + }); + + it('7. only .githooks/pre-commit → workflow-only', () => { + const r = classifyPR(input({ files: ['.githooks/pre-commit'] })); + expect(r.lane).toBe('workflow-only'); + expect(r.outputs.runWorkflowValidation).toBe(true); + }); + + it('8. .github/workflows/ + src/ code → falls through to code lane, both validations on', () => { + const r = classifyPR( + input({ files: ['.github/workflows/ci.yml', 'src/app.ts'] }), + ); + expect(r.lane).toBe('code (node)'); + expect(r.outputs.runNodeCi).toBe(true); + expect(r.outputs.runWorkflowValidation).toBe(true); + expect(r.outputs.runPolicyValidation).toBe(true); + }); +}); + +describe('classifyPR — policy-only lane', () => { + it('9. only AGENTS.md → policy-only', () => { + const r = classifyPR(input({ files: ['AGENTS.md'] })); + expect(r.lane).toBe('policy-only'); + expect(r.outputs.runPolicyValidation).toBe(true); + expect(r.outputs.runWorkflowValidation).toBe(false); + }); + + it('10. only .agent/check-map.yml → policy-only', () => { + const r = classifyPR(input({ files: ['.agent/check-map.yml'] })); + expect(r.lane).toBe('policy-only'); + expect(r.outputs.runPolicyValidation).toBe(true); + }); +}); + +describe('classifyPR — snapshot-refresh lane (Guardrail #3)', () => { + const SNAPSHOT_PATHS = 'src/snapshots/**'; + + it('11. all files match snapshot-paths → snapshot-only', () => { + const r = classifyPR( + input({ + files: ['src/snapshots/foo.yml', 'src/snapshots/bar.yml'], + snapshotPaths: SNAPSHOT_PATHS, + snapshotTestCommand: 'npm run test:snapshots', + }), + ); + expect(r.lane).toBe('snapshot-refresh'); + expect(r.outputs.snapshotOnly).toBe(true); + expect(r.outputs.runSnapshotValidation).toBe(true); + expect(r.outputs.runNodeCi).toBe(false); + }); + + it('12. one matching + one non-matching file → falls through to full node-ci', () => { + const r = classifyPR( + input({ + files: ['src/snapshots/foo.yml', 'src/server/bar.mjs'], + snapshotPaths: SNAPSHOT_PATHS, + snapshotTestCommand: 'npm run test:snapshots', + }), + ); + expect(r.outputs.snapshotOnly).toBe(false); + expect(r.lane).toBe('code (node)'); + expect(r.outputs.runNodeCi).toBe(true); + }); + + it('13. empty snapshotPaths → snapshot-only never fires even on snapshot files', () => { + const r = classifyPR( + input({ + files: ['src/snapshots/foo.yml'], + snapshotPaths: '', + }), + ); + expect(r.outputs.snapshotOnly).toBe(false); + // src/snapshots/foo.yml has no recognized code extension and doesn't match + // codeRe; src/ prefix triggers code-touched. So it lands in code (node). + expect(r.lane).toBe('code (node)'); + }); + + it('14. snapshot-only with empty snapshotTestCommand → snapshot-validation off', () => { + const r = classifyPR( + input({ + files: ['src/snapshots/foo.yml'], + snapshotPaths: SNAPSHOT_PATHS, + snapshotTestCommand: '', + }), + ); + expect(r.outputs.snapshotOnly).toBe(true); + expect(r.outputs.runSnapshotValidation).toBe(false); + }); +}); + +describe('classifyPR — polyglot stack (Guardrail #4)', () => { + const NODE = 'server/**\nweb/**\nelectron/**\npackage.json\npackage-lock.json'; + const PY = 'analysis-service/**'; + + it('15. polyglot with both empty path lists → ok=false', () => { + const r = classifyPR( + input({ stack: 'polyglot', nodePaths: '', pythonPaths: '' }), + ); + expect(r.ok).toBe(false); + expect(r.errors[0]).toMatch(/polyglot requires at least one/); + }); + + it('16. polyglot, node-only PR → runs only node-ci', () => { + const r = classifyPR( + input({ + stack: 'polyglot', + nodePaths: NODE, + pythonPaths: PY, + files: ['server/app.ts'], + }), + ); + expect(r.lane).toBe('code (polyglot: node)'); + expect(r.outputs.runNodeCi).toBe(true); + expect(r.outputs.runPythonCi).toBe(false); + }); + + it('17. polyglot, python-only PR → runs only python-ci', () => { + const r = classifyPR( + input({ + stack: 'polyglot', + nodePaths: NODE, + pythonPaths: PY, + files: ['analysis-service/foo.py'], + }), + ); + expect(r.lane).toBe('code (polyglot: python)'); + expect(r.outputs.runNodeCi).toBe(false); + expect(r.outputs.runPythonCi).toBe(true); + }); + + it('18. polyglot, mixed → both language CIs run', () => { + const r = classifyPR( + input({ + stack: 'polyglot', + nodePaths: NODE, + pythonPaths: PY, + files: ['server/app.ts', 'analysis-service/foo.py'], + }), + ); + expect(r.lane).toBe('code (polyglot: node+python)'); + expect(r.outputs.runNodeCi).toBe(true); + expect(r.outputs.runPythonCi).toBe(true); + }); + + it('19. polyglot, neither (web/ in nodePaths only, file is README.md) → docs-only', () => { + const r = classifyPR( + input({ + stack: 'polyglot', + nodePaths: NODE, + pythonPaths: PY, + files: ['README.md'], + }), + ); + expect(r.lane).toBe('docs-only'); + }); + + it('20. polyglot, package.json bump (root) → node-ci runs by convention', () => { + const r = classifyPR( + input({ + stack: 'polyglot', + nodePaths: NODE, + pythonPaths: PY, + files: ['package.json'], + }), + ); + expect(r.outputs.runNodeCi).toBe(true); + expect(r.outputs.runPythonCi).toBe(false); + expect(r.outputs.runDependencyReview).toBe(true); + }); +}); + +describe('classifyPR — ci:full label override (Guardrail #2)', () => { + it('21. workflow-only PR with ci:full label → forced-full, runs all CI', () => { + const r = classifyPR( + input({ + files: ['.github/workflows/ci.yml'], + labels: ['ci:full'], + stack: 'node', + }), + ); + expect(r.outputs.forcedFull).toBe(true); + expect(r.lane).toBe('forced-full (label: ci:full)'); + expect(r.outputs.runNodeCi).toBe(true); + }); + + it('22. polyglot + ci:full → both language CIs run', () => { + const r = classifyPR( + input({ + stack: 'polyglot', + nodePaths: 'server/**', + pythonPaths: 'analysis-service/**', + files: ['README.md'], + labels: ['ci:full'], + }), + ); + expect(r.outputs.forcedFull).toBe(true); + expect(r.outputs.runNodeCi).toBe(true); + expect(r.outputs.runPythonCi).toBe(true); + }); + + it('23. label match is case-insensitive', () => { + const r = classifyPR( + input({ + files: ['README.md'], + labels: ['CI:Full'], + forceFullCiLabel: 'ci:full', + }), + ); + expect(r.outputs.forcedFull).toBe(true); + }); + + it('24. custom forceFullCiLabel name → matches only that name', () => { + const r = classifyPR( + input({ + files: ['README.md'], + labels: ['ci:full'], + forceFullCiLabel: 'run-everything', + }), + ); + expect(r.outputs.forcedFull).toBe(false); + }); +}); + +describe('classifyPR — non-PR / push events', () => { + it('25. push event → broad routing, lane=push-event', () => { + const r = classifyPR(input({ isPullRequest: false, stack: 'node' })); + expect(r.lane).toMatch(/push-event/); + expect(r.outputs.runNodeCi).toBe(true); + expect(r.outputs.runPythonCi).toBe(false); + expect(r.outputs.runWorkflowValidation).toBe(true); + expect(r.outputs.runPolicyValidation).toBe(true); + }); + + it('26. push event on polyglot stack → both language CIs', () => { + const r = classifyPR( + input({ + isPullRequest: false, + stack: 'polyglot', + nodePaths: 'server/**', + pythonPaths: 'analysis-service/**', + }), + ); + expect(r.outputs.runNodeCi).toBe(true); + expect(r.outputs.runPythonCi).toBe(true); + }); +}); + +describe('classifyPR — error paths', () => { + it('27. unknown stack → ok=false', () => { + const r = classifyPR(input({ stack: 'kotlin' })); + expect(r.ok).toBe(false); + expect(r.errors[0]).toMatch(/Unknown stack/); + }); + + it('27b. stack=minimal with code change → ok=false (legacy invariant)', () => { + const r = classifyPR(input({ stack: 'minimal', files: ['src/foo.ts'] })); + expect(r.ok).toBe(false); + expect(r.errors[0]).toMatch(/stack=minimal/); + }); + + it('27c. stack=minimal with docs-only → ok=true, docs-only lane', () => { + const r = classifyPR(input({ stack: 'minimal', files: ['README.md'] })); + expect(r.ok).toBe(true); + expect(r.lane).toBe('docs-only'); + }); + + it('27d. stack=minimal with workflow-only → ok=true, workflow-only lane', () => { + const r = classifyPR( + input({ stack: 'minimal', files: ['.github/workflows/ci.yml'] }), + ); + expect(r.ok).toBe(true); + expect(r.lane).toBe('workflow-only'); + }); +}); + +describe('classifyPR — internals', () => { + it('28. filesSummary truncates to 30 entries', () => { + const files = Array.from({ length: 50 }, (_, i) => `src/f${i}.ts`); + const r = classifyPR(input({ files })); + const lines = r.filesSummary.split('\n'); + expect(lines.length).toBe(30); + expect(lines[0]).toBe('src/f0.ts'); + expect(lines[29]).toBe('src/f29.ts'); + }); + + it('29. jobsRequired always ends with decision', () => { + const r = classifyPR(input({ files: ['src/app.ts'] })); + expect(r.jobsRequired[r.jobsRequired.length - 1]).toBe('decision'); + }); + + it('30. jobsSkipped does not overlap jobsRequired', () => { + const r = classifyPR(input({ files: ['src/app.ts'] })); + const req = new Set(r.jobsRequired); + for (const j of r.jobsSkipped) { + expect(req.has(j)).toBe(false); + } + }); + + it('31. path matching: `server/**` matches subdirs', () => { + const r = classifyPR( + input({ + stack: 'polyglot', + nodePaths: 'server/**', + pythonPaths: 'analysis-service/**', + files: ['server/api/handlers/foo.ts'], + }), + ); + expect(r.outputs.runNodeCi).toBe(true); + }); + + it('32. path matching: exact filename', () => { + const r = classifyPR( + input({ + stack: 'polyglot', + nodePaths: 'package.json', + pythonPaths: 'analysis-service/**', + files: ['package.json'], + }), + ); + expect(r.outputs.runNodeCi).toBe(true); + }); + + it('33. path matching: exact filename does NOT match a path with that filename in a subdir', () => { + const r = classifyPR( + input({ + stack: 'polyglot', + nodePaths: 'package.json', + pythonPaths: 'analysis-service/**', + files: ['nested/package.json'], + }), + ); + // Exact-match semantics: only top-level package.json matches. + // nested/package.json does NOT match nodePaths and isn't in pythonPaths + // either. It IS however a packageRe match → runDependencyReview true. + // But polyglot fallback: packageTouched && !runNodeCi && !runPythonCi + // && nodePatterns.length > 0 → runNodeCi=true. + expect(r.outputs.runDependencyReview).toBe(true); + expect(r.outputs.runNodeCi).toBe(true); + }); + + it('34. path matching: comments and blanks in pattern lists', () => { + const r = classifyPR( + input({ + stack: 'polyglot', + nodePaths: '# node code\nserver/**\n\n# packages\npackage.json', + pythonPaths: 'analysis-service/**', + files: ['server/foo.ts'], + }), + ); + expect(r.outputs.runNodeCi).toBe(true); + }); +}); + +describe('classifyPR — interaction edge cases', () => { + it('35. ci:full beats snapshot-refresh', () => { + const r = classifyPR( + input({ + files: ['src/snapshots/foo.yml'], + labels: ['ci:full'], + snapshotPaths: 'src/snapshots/**', + snapshotTestCommand: 'npm test', + }), + ); + expect(r.outputs.forcedFull).toBe(true); + expect(r.outputs.snapshotOnly).toBe(false); + expect(r.lane).toMatch(/forced-full/); + }); + + it('36. ci:full beats docs-only', () => { + const r = classifyPR( + input({ files: ['README.md'], labels: ['ci:full'] }), + ); + expect(r.outputs.forcedFull).toBe(true); + expect(r.lane).toMatch(/forced-full/); + }); + + it('37. snapshot-refresh beats docs-only (snapshot files are checked first)', () => { + const r = classifyPR( + input({ + files: ['src/snapshots/foo.md'], + snapshotPaths: 'src/snapshots/**', + snapshotTestCommand: 'npm test', + }), + ); + expect(r.lane).toBe('snapshot-refresh'); + }); +}); From 9c7111700404823803eeda7b9c22112232c3d7ec Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Wed, 20 May 2026 20:57:05 -0500 Subject: [PATCH 2/3] feat(ci): polyglot stack + snapshot-refresh + lane summary + ci:full (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes PRs to the smallest lane that still gives real signal. Replaces the inline detect-step JS with a dynamic-import of scripts/classify-pr.mjs (same pattern as pr-policy + parse-evidence). The single required check remains `repo-required-gate / decision`; per-lane jobs stay conditional under it. New inputs: - stack: polyglot — fans out to node-ci and/or python-ci based on which caller-declared workspace paths the PR touched. node-paths and python-paths default empty; consumers MUST declare at least one when stack=polyglot (fail-fast, Guardrail #4). - snapshot-paths + snapshot-test-command — PRs whose EVERY file matches snapshot-paths skip language CI and run the consumer's snapshot test instead (Guardrail #3, ALL files must match). - force-full-ci-label (default `ci:full`) — Release-Admiral's escape hatch when a workflow-only PR could still break CI semantically (Guardrail #2). - workflow-library-ref (default `v1`) — pins the classifier checkout to the same ref callers pin this workflow at. Detect job now emits a one-line core.notice + step-summary block with the lane label, changed files, and required/skipped job list so the routing decision is legible from the Actions list view (Guardrail #5). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/repo-required-gate.yml | 325 ++++++++++++++++++----- 1 file changed, 252 insertions(+), 73 deletions(-) diff --git a/.github/workflows/repo-required-gate.yml b/.github/workflows/repo-required-gate.yml index 5bf7855..93105bb 100644 --- a/.github/workflows/repo-required-gate.yml +++ b/.github/workflows/repo-required-gate.yml @@ -1,14 +1,28 @@ name: Repo Required Gate (reusable) -# Single required branch-protection surface for consumer repos. -# Consumers should require only `repo-required-gate / decision`. -# This workflow must be called by a caller that has no path filters. +# Single required branch-protection surface for consumer repos. Consumers +# require only `repo-required-gate / decision`. Per-lane jobs remain +# conditional under that umbrella — never promote them to required (would +# trap mergeability on path-skipped runs). See Iridescent-Church plan +# (2026-05-20) Guardrail #1. +# +# The detect job classifies the PR into ONE of these lanes: +# docs-only, workflow-only, policy-only, snapshot-refresh, +# code (node|python|polyglot: node|python|node+python), +# forced-full (label: ci:full) +# and gates downstream jobs accordingly. Classification logic lives in +# scripts/classify-pr.mjs and is checked out from this repo via the +# `workflow-library-ref` input (same pattern as pr-policy.yml). +# +# Lane is also surfaced as a one-line core.notice + a step-summary block +# so the PR's Actions view shows WHY a small PR didn't run everything +# (Guardrail #5). on: workflow_call: inputs: stack: - description: "Project stack: node, python, or minimal." + description: "Project stack: node, python, minimal, or polyglot." type: string default: "minimal" working-directory: @@ -16,15 +30,15 @@ on: type: string default: "." node-versions: - description: 'JSON array of Node versions for Node CI.' + description: "JSON array of Node versions for Node CI." type: string default: '["22"]' python-versions: - description: 'JSON array of Python versions for Python CI.' + description: "JSON array of Python versions for Python CI." type: string default: '["3.12"]' os-matrix: - description: 'JSON array of OS runners for language CI.' + description: "JSON array of OS runners for language CI." type: string default: '["ubuntu-latest"]' branch-pattern: @@ -79,6 +93,52 @@ on: python-test-command: type: string default: "pytest" + # ---- New inputs (Iridescent-Church 2026-05-20) ----------------------- + node-paths: + description: | + Newline-separated patterns selecting Node-owned workspace roots. + Required when stack=polyglot (unless python-paths is set). Patterns + end in `/**` for prefix match, `/` for prefix match, or an exact + filename. Empty for non-polyglot stacks. + type: string + default: "" + python-paths: + description: | + Newline-separated patterns selecting Python-owned workspace roots. + Same syntax as node-paths. Required when stack=polyglot (unless + node-paths is set). + type: string + default: "" + snapshot-paths: + description: | + Newline-separated patterns identifying snapshot files. When EVERY + changed file matches, the PR routes to the snapshot-refresh lane + and skips language CI. Empty disables the lane. (Guardrail #3: ALL + files must match, not any.) + type: string + default: "" + snapshot-test-command: + description: | + Shell command run by the snapshot-validation job when snapshot-only + fires. Empty = the snapshot-refresh lane skips validation + entirely (lane still reports for visibility). + type: string + default: "" + force-full-ci-label: + description: | + PR label name that forces full language CI regardless of path + routing. Release-Admiral's escape hatch when a workflow-only PR + could still break CI semantically (Guardrail #2). Match is + case-insensitive. + type: string + default: "ci:full" + workflow-library-ref: + description: | + Git ref (tag, branch, or SHA) of ArchonVII/github-workflows used + to source scripts/classify-pr.mjs. MUST match the ref the caller + pins this reusable workflow to. Defaults to v1. + type: string + default: v1 jobs: detect: @@ -88,74 +148,148 @@ jobs: pull-requests: read contents: read outputs: + ok: ${{ steps.detect.outputs.ok }} + lane: ${{ steps.detect.outputs.lane }} docs-only: ${{ steps.detect.outputs.docs-only }} run-ci: ${{ steps.detect.outputs.run-ci }} + run-node-ci: ${{ steps.detect.outputs.run-node-ci }} + run-python-ci: ${{ steps.detect.outputs.run-python-ci }} run-dependency-review: ${{ steps.detect.outputs.run-dependency-review }} run-workflow-validation: ${{ steps.detect.outputs.run-workflow-validation }} run-policy-validation: ${{ steps.detect.outputs.run-policy-validation }} + snapshot-only: ${{ steps.detect.outputs.snapshot-only }} + run-snapshot-validation: ${{ steps.detect.outputs.run-snapshot-validation }} + forced-full: ${{ steps.detect.outputs.forced-full }} files-summary: ${{ steps.detect.outputs.files-summary }} steps: + # Check out THIS reusable workflow's repo so we can dynamic-import + # scripts/classify-pr.mjs. Mirrors pr-policy.yml's parser-checkout + # pattern. Pin to the same ref the caller pins us at via + # `workflow-library-ref` (default v1) so parser+workflow are versioned + # together. + - name: Check out github-workflows for classifier script + uses: actions/checkout@v4 + with: + repository: ArchonVII/github-workflows + ref: ${{ inputs.workflow-library-ref }} + path: __github-workflows__ + - name: Classify changed files id: detect uses: actions/github-script@v7 env: STACK: ${{ inputs.stack }} + NODE_PATHS: ${{ inputs.node-paths }} + PYTHON_PATHS: ${{ inputs.python-paths }} + SNAPSHOT_PATHS: ${{ inputs.snapshot-paths }} + SNAPSHOT_TEST_COMMAND: ${{ inputs.snapshot-test-command }} + FORCE_FULL_CI_LABEL: ${{ inputs.force-full-ci-label }} + WORKFLOW_LIBRARY_REF: ${{ inputs.workflow-library-ref }} with: script: | - const pr = context.payload.pull_request; - if (!pr) { - core.setOutput('docs-only', 'false'); - core.setOutput('run-ci', String(process.env.STACK !== 'minimal')); - core.setOutput('run-dependency-review', 'false'); - core.setOutput('run-workflow-validation', 'true'); - core.setOutput('run-policy-validation', 'true'); - core.setOutput('files-summary', 'non-PR event: broad validation; language CI only for non-minimal stacks'); + const path = require('path'); + const url = require('url'); + + const classifierPath = path.resolve( + process.env.GITHUB_WORKSPACE, + '__github-workflows__/scripts/classify-pr.mjs', + ); + const ref = process.env.WORKFLOW_LIBRARY_REF || '(unset)'; + + let classifyPR; + try { + ({ classifyPR } = await import(url.pathToFileURL(classifierPath).href)); + } catch (err) { + const msg = `Could not load classifier at ${classifierPath} (workflow-library-ref=${ref}): ${err && err.message ? err.message : err}.`; + core.setFailed(msg); return; } - const files = await github.paginate(github.rest.pulls.listFiles, { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - per_page: 100, - }); + const pr = context.payload.pull_request; + const isPullRequest = !!pr; - const names = files.map((file) => file.filename); - const docRe = /\.(md|mdx|txt|png|jpg|jpeg|gif|svg|webp|bmp|ico|avif)$/i; - const codeRe = /\.(c|cc|cpp|cs|go|java|js|jsx|ts|tsx|mjs|cjs|py|rs|rb|php|swift|kt|kts|scala)$/i; - const packageRe = /(^|\/)(package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|pyproject\.toml|requirements(-dev)?\.txt|uv\.lock|poetry\.lock|Pipfile(\.lock)?|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum)$/; - const docsOnly = names.length > 0 && names.every((name) => - docRe.test(name) || name.startsWith('.changelog/') || name.startsWith('docs/') - ); - const workflowOrHook = names.some((name) => - name.startsWith('.github/workflows/') || name.startsWith('.githooks/') - ); - const policy = names.some((name) => - name === 'AGENTS.md' || - name === 'CLAUDE.md' || - name === 'GEMINI.md' || - name === '.agent/check-map.yml' || - name.startsWith('.agent/schema/') || - name.startsWith('.github/') - ); - const packageTouched = names.some((name) => packageRe.test(name)); - const codeTouched = names.some((name) => { - if (name.startsWith('.github/') || name.startsWith('.githooks/') || name.startsWith('.agent/')) return false; - if (docRe.test(name) || name.startsWith('.changelog/')) return false; - return codeRe.test(name) || /^(src|test|tests|lib|bin|scripts)\//.test(name); + let files = []; + let labels = []; + if (isPullRequest) { + const filesPaged = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100, + }); + files = filesPaged.map((f) => f.filename); + labels = (pr.labels || []).map((l) => l.name); + } + + const result = classifyPR({ + files, + labels, + stack: process.env.STACK, + nodePaths: process.env.NODE_PATHS, + pythonPaths: process.env.PYTHON_PATHS, + snapshotPaths: process.env.SNAPSHOT_PATHS, + snapshotTestCommand: process.env.SNAPSHOT_TEST_COMMAND, + forceFullCiLabel: process.env.FORCE_FULL_CI_LABEL, + isPullRequest, }); - core.setOutput('docs-only', String(docsOnly)); - core.setOutput('run-ci', String(packageTouched || codeTouched)); - core.setOutput('run-dependency-review', String(packageTouched)); - core.setOutput('run-workflow-validation', String(workflowOrHook)); - core.setOutput('run-policy-validation', String(policy)); - core.setOutput('files-summary', names.slice(0, 30).join('\n')); + // Emit ok + every output for downstream jobs. + core.setOutput('ok', String(result.ok)); + core.setOutput('lane', result.lane); + core.setOutput('docs-only', String(result.outputs.docsOnly)); + core.setOutput('run-ci', String(result.outputs.runCi)); + core.setOutput('run-node-ci', String(result.outputs.runNodeCi)); + core.setOutput('run-python-ci', String(result.outputs.runPythonCi)); + core.setOutput('run-dependency-review', String(result.outputs.runDependencyReview)); + core.setOutput('run-workflow-validation', String(result.outputs.runWorkflowValidation)); + core.setOutput('run-policy-validation', String(result.outputs.runPolicyValidation)); + core.setOutput('snapshot-only', String(result.outputs.snapshotOnly)); + core.setOutput('run-snapshot-validation', String(result.outputs.runSnapshotValidation)); + core.setOutput('forced-full', String(result.outputs.forcedFull)); + core.setOutput('files-summary', result.filesSummary); + + // Lane summary block: shows up in the Actions UI summary AND as + // a one-line core.notice so the lane label appears in the list + // view, not only inside the run (Guardrail #5). + core.notice(`Lane: ${result.lane}`, { title: 'repo-required-gate' }); + + const changedBlock = files.length > 0 + ? files.slice(0, 30).map((f) => ` - ${f}`).join('\n') + : ' (no files / non-PR event)'; + const requiredBlock = result.jobsRequired.map((j) => ` - ${j}`).join('\n'); + const skippedBlock = result.jobsSkipped.length > 0 + ? result.jobsSkipped.map((j) => ` - ${j}`).join('\n') + : ' (none)'; + + await core.summary + .addHeading('Repo Required Gate — lane decision', 3) + .addCodeBlock( + [ + `Lane: ${result.lane}`, + '', + 'Changed files:', + changedBlock, + '', + 'Jobs required:', + requiredBlock, + '', + 'Jobs skipped:', + skippedBlock, + ].join('\n'), + 'text', + ) + .write(); + + // Fail fast on classifier config errors (e.g. stack=polyglot with + // empty node-paths AND python-paths, or stack=minimal+code). + if (!result.ok) { + core.setFailed(result.errors.join('\n')); + } pr-contract: name: pr contract needs: detect - if: inputs.run-pr-contract && github.event_name == 'pull_request' + if: inputs.run-pr-contract && github.event_name == 'pull_request' && needs.detect.outputs.ok == 'true' runs-on: ubuntu-latest permissions: pull-requests: read @@ -206,7 +340,7 @@ jobs: workflow-validation: name: workflow and hook validation needs: detect - if: inputs.run-workflow-validation && needs.detect.outputs.run-workflow-validation == 'true' + if: inputs.run-workflow-validation && needs.detect.outputs.ok == 'true' && needs.detect.outputs.run-workflow-validation == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -236,7 +370,7 @@ jobs: policy-validation: name: policy validation needs: detect - if: inputs.run-policy-validation && needs.detect.outputs.run-policy-validation == 'true' + if: inputs.run-policy-validation && needs.detect.outputs.ok == 'true' && needs.detect.outputs.run-policy-validation == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -254,7 +388,7 @@ jobs: dependency-review: name: dependency review needs: detect - if: inputs.run-dependency-review && github.event_name == 'pull_request' && needs.detect.outputs.run-dependency-review == 'true' + if: inputs.run-dependency-review && github.event_name == 'pull_request' && needs.detect.outputs.ok == 'true' && needs.detect.outputs.run-dependency-review == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -271,7 +405,7 @@ jobs: node-ci: name: node ci needs: detect - if: inputs.stack == 'node' && needs.detect.outputs.run-ci == 'true' + if: needs.detect.outputs.ok == 'true' && needs.detect.outputs.run-node-ci == 'true' uses: ArchonVII/github-workflows/.github/workflows/node-ci.yml@v1 with: node-versions: ${{ inputs.node-versions }} @@ -285,7 +419,7 @@ jobs: python-ci: name: python ci needs: detect - if: inputs.stack == 'python' && needs.detect.outputs.run-ci == 'true' + if: needs.detect.outputs.ok == 'true' && needs.detect.outputs.run-python-ci == 'true' uses: ArchonVII/github-workflows/.github/workflows/python-ci.yml@v1 with: python-versions: ${{ inputs.python-versions }} @@ -297,6 +431,42 @@ jobs: typecheck-command: ${{ inputs.python-typecheck-command }} test-command: ${{ inputs.python-test-command }} + snapshot-validation: + name: snapshot validation + needs: detect + if: needs.detect.outputs.ok == 'true' && needs.detect.outputs.run-snapshot-validation == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ fromJSON(inputs.node-versions)[0] }} + cache: "npm" + + - name: Detect package manager + id: pm + shell: bash + run: | + if [ -f pnpm-lock.yaml ]; then + npm install -g pnpm + echo "install=pnpm install --frozen-lockfile" >> "$GITHUB_OUTPUT" + elif [ -f yarn.lock ]; then + echo "install=yarn install --frozen-lockfile" >> "$GITHUB_OUTPUT" + else + echo "install=npm ci" >> "$GITHUB_OUTPUT" + fi + + - name: Install + shell: bash + run: ${{ steps.pm.outputs.install }} + + - name: Run snapshot test command + shell: bash + run: ${{ inputs.snapshot-test-command }} + decision: name: decision needs: @@ -307,17 +477,21 @@ jobs: - dependency-review - node-ci - python-ci + - snapshot-validation if: always() runs-on: ubuntu-latest steps: - name: Decide required gate shell: bash env: - STACK: ${{ inputs.stack }} - RUN_CI: ${{ needs.detect.outputs.run-ci }} + LANE: ${{ needs.detect.outputs.lane }} + OK: ${{ needs.detect.outputs.ok }} + RUN_NODE_CI: ${{ needs.detect.outputs.run-node-ci }} + RUN_PYTHON_CI: ${{ needs.detect.outputs.run-python-ci }} RUN_DEPENDENCY_REVIEW: ${{ needs.detect.outputs.run-dependency-review }} RUN_WORKFLOW_VALIDATION: ${{ needs.detect.outputs.run-workflow-validation }} RUN_POLICY_VALIDATION: ${{ needs.detect.outputs.run-policy-validation }} + RUN_SNAPSHOT_VALIDATION: ${{ needs.detect.outputs.run-snapshot-validation }} DETECT_RESULT: ${{ needs.detect.result }} CONTRACT_RESULT: ${{ needs.pr-contract.result }} WORKFLOW_RESULT: ${{ needs.workflow-validation.result }} @@ -325,6 +499,9 @@ jobs: DEPENDENCY_RESULT: ${{ needs.dependency-review.result }} NODE_RESULT: ${{ needs.node-ci.result }} PYTHON_RESULT: ${{ needs.python-ci.result }} + SNAPSHOT_RESULT: ${{ needs.snapshot-validation.result }} + EVENT_NAME: ${{ github.event_name }} + RUN_PR_CONTRACT: ${{ inputs.run-pr-contract }} run: | set -euo pipefail @@ -338,9 +515,14 @@ jobs: fi } + if [ "$OK" != "true" ]; then + echo "::error::detect job classified the PR as a config error; see detect-job summary." + failed=1 + fi + require_success "detect changes" "$DETECT_RESULT" - if [ "${{ inputs.run-pr-contract }}" = "true" ] && [ "${{ github.event_name }}" = "pull_request" ]; then + if [ "$RUN_PR_CONTRACT" = "true" ] && [ "$EVENT_NAME" = "pull_request" ]; then require_success "pr contract" "$CONTRACT_RESULT" fi @@ -356,23 +538,20 @@ jobs: require_success "dependency review" "$DEPENDENCY_RESULT" fi - if [ "$RUN_CI" = "true" ]; then - case "$STACK" in - node) require_success "node ci" "$NODE_RESULT" ;; - python) require_success "python ci" "$PYTHON_RESULT" ;; - minimal) - echo "::error::code/package changes require Node or Python CI; stack=minimal is only for docs/config repos" - failed=1 - ;; - *) - echo "::error::unknown stack '$STACK'; expected node, python, or minimal" - failed=1 - ;; - esac + if [ "$RUN_NODE_CI" = "true" ]; then + require_success "node ci" "$NODE_RESULT" + fi + + if [ "$RUN_PYTHON_CI" = "true" ]; then + require_success "python ci" "$PYTHON_RESULT" + fi + + if [ "$RUN_SNAPSHOT_VALIDATION" = "true" ]; then + require_success "snapshot validation" "$SNAPSHOT_RESULT" fi if [ "$failed" -ne 0 ]; then exit 1 fi - echo "repo-required-gate passed" + echo "repo-required-gate passed for lane: $LANE" From 0f40319967a551c7c42d78361c8df0c182198acf Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Wed, 20 May 2026 20:57:11 -0500 Subject: [PATCH 3/3] docs(examples): show polyglot, snapshot, and ci:full lane shapes (#8) Consumers can copy the relevant block for their stack. Also adds `labeled, unlabeled` to the pull_request event types so applying the ci:full label on an open PR triggers a fresh gate run. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/repo-required-gate.yml | 60 +++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/examples/repo-required-gate.yml b/examples/repo-required-gate.yml index 72da325..5f2ae5b 100644 --- a/examples/repo-required-gate.yml +++ b/examples/repo-required-gate.yml @@ -7,7 +7,16 @@ name: Repo Required Gate on: pull_request: branches: [main] - types: [opened, edited, synchronize, reopened, ready_for_review] + types: + [ + opened, + edited, + synchronize, + reopened, + ready_for_review, + labeled, + unlabeled, + ] merge_group: permissions: @@ -19,12 +28,59 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # ------------------------------------------------------------------------- + # Pick ONE of the call shapes below for your consumer's CI. Comment the + # others. The gate auto-routes PRs to the smallest lane that still gives + # real signal: docs-only / workflow-only / policy-only / snapshot-refresh / + # code (node|python|polyglot) / forced-full (via the `ci:full` label). + # ------------------------------------------------------------------------- repo-required-gate: uses: ArchonVII/github-workflows/.github/workflows/repo-required-gate.yml@v1 with: + # ------ Minimal (docs / config repos with no language CI) ----------- stack: minimal + + # ------ Node-only stack --------------------------------------------- # stack: node + # node-versions: '["22"]' + # npm-test-script: test + # npm-build-script: build + + # ------ Python-only stack ------------------------------------------- # stack: python - # working-directory: "." + # python-versions: '["3.12"]' + # python-use-uv: true + + # ------ Polyglot (Node + Python in one repo) ------------------------ + # When stack=polyglot, you MUST declare at least one of node-paths or + # python-paths. Files are matched with `path/**` (prefix), `path/` + # (prefix), or exact filename. node-ci runs only when a node-path file + # changed; python-ci runs only when a python-path file changed. + # + # stack: polyglot # node-versions: '["22"]' # python-versions: '["3.12"]' + # node-paths: | + # server/** + # web/** + # electron/** + # package.json + # package-lock.json + # python-paths: | + # analysis-service/** + + # ------ Snapshot-refresh lane (any stack) --------------------------- + # PRs that touch ONLY paths matching snapshot-paths skip language CI + # and instead run snapshot-test-command. ALL changed files must match + # (one non-matching file falls through to the normal lane). + # + # snapshot-paths: | + # src/snapshots/ + # snapshot-test-command: npm run test:snapshots + + # ------ Force-full escape hatch (any stack) ------------------------- + # Default label is `ci:full`. Apply it to a PR to bypass path-based + # routing and run the full language CI surface — e.g. a workflow-only + # change that semantically affects CI. + # + # force-full-ci-label: ci:full