From 7b531c09b0133f31cbdaf72fc2d742fe038ec8b9 Mon Sep 17 00:00:00 2001 From: Tim Stranske Date: Mon, 4 May 2026 11:54:55 -0500 Subject: [PATCH 1/4] feat: add path-classifier action --- .github/actions/path-classifier/action.yml | 54 +++ .github/actions/path-classifier/classify.js | 348 ++++++++++++++++++ .github/path-classification.yml | 35 ++ .../__tests__/path-classifier-replay.test.js | 90 +++++ .../scripts/__tests__/path-classifier.test.js | 117 ++++++ .github/sync-manifest.yml | 7 + .../maint-68-sync-consumer-repos.yml | 2 + .github/workflows/selftest-ci.yml | 7 + .../actions/path-classifier/action.yml | 54 +++ .../actions/path-classifier/classify.js | 348 ++++++++++++++++++ .../.github/path-classification.yml | 35 ++ 11 files changed, 1097 insertions(+) create mode 100644 .github/actions/path-classifier/action.yml create mode 100644 .github/actions/path-classifier/classify.js create mode 100644 .github/path-classification.yml create mode 100644 .github/scripts/__tests__/path-classifier-replay.test.js create mode 100644 .github/scripts/__tests__/path-classifier.test.js create mode 100644 templates/consumer-repo/.github/actions/path-classifier/action.yml create mode 100644 templates/consumer-repo/.github/actions/path-classifier/classify.js create mode 100644 templates/consumer-repo/.github/path-classification.yml diff --git a/.github/actions/path-classifier/action.yml b/.github/actions/path-classifier/action.yml new file mode 100644 index 000000000..ce895316e --- /dev/null +++ b/.github/actions/path-classifier/action.yml @@ -0,0 +1,54 @@ +name: Path Classifier +description: Classify changed PR paths into workflow relevance categories. + +inputs: + base-ref: + description: Base ref or SHA to diff against. Defaults to PR base or push before SHA. + required: false + config-path: + description: Path classification config file. + required: false + default: .github/path-classification.yml + force-full: + description: Set true to force every category output to true. + required: false + default: 'false' + +outputs: + is-docs-only: + description: True when every changed path matches the docs-only category. + value: ${{ steps.classify.outputs.is-docs-only }} + is-python-code: + description: True when any changed path is Python-code relevant. + value: ${{ steps.classify.outputs.is-python-code }} + is-workflow-change: + description: True when any changed path affects GitHub workflows or actions. + value: ${{ steps.classify.outputs.is-workflow-change }} + is-security-relevant: + description: True when any changed path is security-scan relevant. + value: ${{ steps.classify.outputs.is-security-relevant }} + is-template-change: + description: True when any changed path affects consumer templates. + value: ${{ steps.classify.outputs.is-template-change }} + is-test-only: + description: True when every changed path matches the test-only category. + value: ${{ steps.classify.outputs.is-test-only }} + affected-consumers: + description: JSON list of affected consumers. Reserved for Wave 2. + value: ${{ steps.classify.outputs.affected-consumers }} + classification-rationale: + description: Human-readable classification rationale. + value: ${{ steps.classify.outputs.classification-rationale }} + +runs: + using: composite + steps: + - name: Classify changed paths + id: classify + shell: bash + env: + INPUT_BASE_REF: ${{ inputs.base-ref }} + INPUT_CONFIG_PATH: ${{ inputs.config-path }} + INPUT_FORCE_FULL: ${{ inputs.force-full }} + GITHUB_CONTEXT_JSON: ${{ toJSON(github) }} + run: node "$GITHUB_ACTION_PATH/classify.js" diff --git a/.github/actions/path-classifier/classify.js b/.github/actions/path-classifier/classify.js new file mode 100644 index 000000000..dbc081a0f --- /dev/null +++ b/.github/actions/path-classifier/classify.js @@ -0,0 +1,348 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const OUTPUT_NAMES = { + 'docs-only': 'is-docs-only', + 'python-code': 'is-python-code', + 'workflow-change': 'is-workflow-change', + 'security-relevant': 'is-security-relevant', + 'template-change': 'is-template-change', + 'test-only': 'is-test-only', +}; + +const DEFAULT_CATEGORIES = { + 'docs-only': { paths: ['docs/**', '*.md', 'README.md'], requireAll: true }, + 'python-code': { paths: ['**/*.py', 'pyproject.toml', 'requirements*.txt'], requireAll: false }, + 'workflow-change': { paths: ['.github/workflows/**', '.github/actions/**'], requireAll: false }, + 'security-relevant': { + paths: ['scripts/**', 'tools/**', '.github/workflows/**', 'pyproject.toml'], + requireAll: false, + }, + 'template-change': { paths: ['templates/**'], requireAll: false }, + 'test-only': { paths: ['tests/**', '**/test_*.py', '**/*.test.js'], requireAll: true }, +}; + +function normalizePath(value) { + return String(value || '').replace(/\\/g, '/').replace(/^\.\/+/, ''); +} + +function escapeRegExp(value) { + return value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&'); +} + +function globToRegExp(glob) { + const pattern = normalizePath(glob); + let out = '^'; + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern[index]; + const next = pattern[index + 1]; + if (char === '*') { + if (next === '*') { + const after = pattern[index + 2]; + if (after === '/') { + out += '(?:.*/)?'; + index += 2; + } else { + out += '.*'; + index += 1; + } + } else { + out += '[^/]*'; + } + } else if (char === '?') { + out += '[^/]'; + } else { + out += escapeRegExp(char); + } + } + out += '$'; + return new RegExp(out); +} + +function matchesAny(filePath, patterns) { + const normalized = normalizePath(filePath); + return patterns.some((pattern) => globToRegExp(pattern).test(normalized)); +} + +function parseScalar(value) { + const trimmed = String(value || '').trim(); + if (trimmed === 'true') { + return true; + } + if (trimmed === 'false') { + return false; + } + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function parseInlineList(value) { + const trimmed = String(value || '').trim(); + if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) { + return null; + } + const body = trimmed.slice(1, -1).trim(); + if (!body) { + return []; + } + return body.split(',').map((entry) => parseScalar(entry)); +} + +function parseClassificationConfig(raw) { + const categories = {}; + const lines = String(raw || '').split(/\r?\n/); + let section = null; + let category = null; + let listKey = null; + + for (const line of lines) { + const withoutComment = line.replace(/\s+#.*$/, ''); + if (!withoutComment.trim()) { + continue; + } + const indent = withoutComment.match(/^ */)[0].length; + const trimmed = withoutComment.trim(); + + if (indent === 0 && trimmed.endsWith(':')) { + section = trimmed.slice(0, -1); + category = null; + listKey = null; + continue; + } + + if (section !== 'categories') { + continue; + } + + if (indent === 2 && trimmed.endsWith(':')) { + category = trimmed.slice(0, -1); + categories[category] = categories[category] || {}; + listKey = null; + continue; + } + + if (!category) { + continue; + } + + if (indent === 4 && trimmed.includes(':')) { + const [key, ...rest] = trimmed.split(':'); + const value = rest.join(':').trim(); + const normalizedKey = key === 'require-all' ? 'requireAll' : key; + if (!value) { + categories[category][normalizedKey] = []; + listKey = normalizedKey; + continue; + } + const inlineList = parseInlineList(value); + categories[category][normalizedKey] = inlineList === null ? parseScalar(value) : inlineList; + listKey = null; + continue; + } + + if (indent >= 6 && listKey && trimmed.startsWith('- ')) { + categories[category][listKey].push(parseScalar(trimmed.slice(2))); + } + } + + return { categories }; +} + +function loadConfig(configPath) { + const resolved = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd(), configPath); + if (!fs.existsSync(resolved)) { + return { categories: DEFAULT_CATEGORIES, configPath: resolved, usedDefault: true }; + } + const parsed = parseClassificationConfig(fs.readFileSync(resolved, 'utf8')); + const categories = {}; + for (const [name, fallback] of Object.entries(DEFAULT_CATEGORIES)) { + const configured = parsed.categories[name] || {}; + categories[name] = { + paths: Array.isArray(configured.paths) ? configured.paths : fallback.paths, + requireAll: + typeof configured.requireAll === 'boolean' ? configured.requireAll : fallback.requireAll, + }; + } + return { categories, configPath: resolved, usedDefault: false }; +} + +function parseGithubContext() { + try { + return JSON.parse(process.env.GITHUB_CONTEXT_JSON || '{}'); + } catch { + return {}; + } +} + +function runGit(args) { + return execFileSync('git', args, { + cwd: process.env.GITHUB_WORKSPACE || process.cwd(), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function tryGit(args) { + try { + return runGit(args); + } catch { + return ''; + } +} + +function resolveBaseRef(inputBaseRef, githubContext) { + if (inputBaseRef) { + return inputBaseRef; + } + if (githubContext.event_name === 'pull_request' && githubContext.base_ref) { + return `origin/${githubContext.base_ref}`; + } + const event = githubContext.event || {}; + if (event.before && !/^0+$/.test(event.before)) { + return event.before; + } + return ''; +} + +function fetchBaseRef(baseRef, githubContext) { + if (!baseRef || !baseRef.startsWith('origin/')) { + return; + } + const branch = baseRef.slice('origin/'.length); + tryGit(['fetch', '--no-tags', '--depth=1', 'origin', branch]); + const prBaseSha = githubContext.event?.pull_request?.base?.sha; + if (prBaseSha) { + tryGit(['fetch', '--no-tags', '--depth=1', 'origin', prBaseSha]); + } +} + +function listChangedFiles({ baseRef, githubContext } = {}) { + const envFiles = process.env.PATH_CLASSIFIER_FILES_JSON; + if (envFiles) { + const parsed = JSON.parse(envFiles); + if (!Array.isArray(parsed)) { + throw new Error('PATH_CLASSIFIER_FILES_JSON must be a JSON array'); + } + return parsed.map(normalizePath).filter(Boolean); + } + + fetchBaseRef(baseRef, githubContext); + const head = githubContext.sha || 'HEAD'; + const ranges = []; + if (baseRef) { + ranges.push(`${baseRef}...${head}`); + ranges.push(`${baseRef}..${head}`); + } + const prBaseSha = githubContext.event?.pull_request?.base?.sha; + if (prBaseSha) { + ranges.push(`${prBaseSha}...${head}`); + ranges.push(`${prBaseSha}..${head}`); + } + + for (const range of ranges) { + const output = tryGit(['diff', '--name-only', range]); + if (output) { + return output.split(/\r?\n/).map(normalizePath).filter(Boolean); + } + } + return []; +} + +function classifyFiles(files, config, { forceFull = false, conservativeFull = false } = {}) { + const changedFiles = Array.from(new Set((files || []).map(normalizePath).filter(Boolean))); + const outputs = {}; + const matched = {}; + + for (const [category, rule] of Object.entries(config.categories)) { + const outputName = OUTPUT_NAMES[category] || `is-${category}`; + const patterns = Array.isArray(rule.paths) ? rule.paths : []; + const matches = changedFiles.filter((filePath) => matchesAny(filePath, patterns)); + let enabled; + if (forceFull || conservativeFull) { + enabled = true; + } else if (changedFiles.length === 0) { + enabled = false; + } else if (rule.requireAll) { + enabled = matches.length === changedFiles.length; + } else { + enabled = matches.length > 0; + } + outputs[outputName] = enabled ? 'true' : 'false'; + matched[category] = matches; + } + + outputs['affected-consumers'] = '[]'; + const trueOutputs = Object.entries(outputs) + .filter(([key, value]) => key.startsWith('is-') && value === 'true') + .map(([key]) => key.replace(/^is-/, '')); + const mode = forceFull ? 'force-full' : conservativeFull ? 'conservative-full' : 'classified'; + outputs['classification-rationale'] = + `${mode}: ${changedFiles.length} changed file(s); ` + + (trueOutputs.length ? `matched ${trueOutputs.join(', ')}` : 'no categories matched'); + + return { outputs, changedFiles, matched }; +} + +function writeOutputs(outputs) { + const outputPath = process.env.GITHUB_OUTPUT; + for (const [key, value] of Object.entries(outputs)) { + console.log(`${key}=${value}`); + } + if (!outputPath) { + return; + } + const lines = []; + for (const [key, value] of Object.entries(outputs)) { + if (String(value).includes('\n')) { + lines.push(`${key}<=${SINCE}`, + '--json', + 'number,mergedAt,files', + ]); + return JSON.parse(raw); +} + +function expectRequireAll(files, patterns) { + return files.length > 0 && files.every((filePath) => matchesAny(filePath, patterns)); +} + +function expectAny(files, patterns) { + return files.some((filePath) => matchesAny(filePath, patterns)); +} + +test('historical merged PR replay matches classification expectations for last 90 days', { + skip: process.env.PATH_CLASSIFIER_REPLAY !== '1' + ? 'set PATH_CLASSIFIER_REPLAY=1 to run live GitHub replay' + : false, +}, () => { + requireGh(); + const pullRequests = listRecentMergedPullRequests(); + assert.ok(pullRequests.length > 0, 'expected at least one merged PR in replay window'); + + const failures = []; + for (const pr of pullRequests) { + const files = (pr.files || []).map((file) => file.path).filter(Boolean); + const outputs = classifyFiles(files, CONFIG).outputs; + const expected = { + 'is-docs-only': expectRequireAll(files, CONFIG.categories['docs-only'].paths), + 'is-python-code': expectAny(files, CONFIG.categories['python-code'].paths), + 'is-workflow-change': expectAny(files, CONFIG.categories['workflow-change'].paths), + 'is-security-relevant': expectAny(files, CONFIG.categories['security-relevant'].paths), + 'is-template-change': expectAny(files, CONFIG.categories['template-change'].paths), + 'is-test-only': expectRequireAll(files, CONFIG.categories['test-only'].paths), + }; + + for (const [name, expectedValue] of Object.entries(expected)) { + if (outputs[name] !== String(expectedValue)) { + failures.push( + `PR #${pr.number} ${name}: expected ${expectedValue}, got ${outputs[name]} for ${files.join(', ')}`, + ); + } + } + } + + assert.deepEqual(failures, []); +}); diff --git a/.github/scripts/__tests__/path-classifier.test.js b/.github/scripts/__tests__/path-classifier.test.js new file mode 100644 index 000000000..f4eab7fe0 --- /dev/null +++ b/.github/scripts/__tests__/path-classifier.test.js @@ -0,0 +1,117 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + DEFAULT_CATEGORIES, + classifyFiles, + globToRegExp, + matchesAny, + parseClassificationConfig, +} = require('../../actions/path-classifier/classify.js'); + +const CONFIG = { categories: DEFAULT_CATEGORIES }; + +function outputsFor(files, options) { + return classifyFiles(files, CONFIG, options).outputs; +} + +test('glob matcher handles root and nested patterns', () => { + assert.match('README.md', globToRegExp('*.md')); + assert.doesNotMatch('docs/README.md', globToRegExp('*.md')); + assert.equal(matchesAny('pkg/module.py', ['**/*.py']), true); + assert.equal(matchesAny('module.py', ['**/*.py']), true); + assert.equal(matchesAny('.github/actions/path-classifier/action.yml', ['.github/actions/**']), true); +}); + +test('classifies docs-only changes when every path matches docs rules', () => { + const outputs = outputsFor(['README.md', 'docs/usage.md']); + assert.equal(outputs['is-docs-only'], 'true'); + assert.equal(outputs['is-python-code'], 'false'); + assert.equal(outputs['is-security-relevant'], 'false'); +}); + +test('classifies python code changes', () => { + const outputs = outputsFor(['src/app.py', 'requirements-dev.txt']); + assert.equal(outputs['is-python-code'], 'true'); + assert.equal(outputs['is-docs-only'], 'false'); +}); + +test('classifies workflow and security relevant changes', () => { + const outputs = outputsFor(['.github/workflows/pr-00-gate.yml']); + assert.equal(outputs['is-workflow-change'], 'true'); + assert.equal(outputs['is-security-relevant'], 'true'); +}); + +test('classifies security relevant tool and pyproject changes', () => { + const outputs = outputsFor(['tools/enforce_gate_branch_protection.py', 'pyproject.toml']); + assert.equal(outputs['is-security-relevant'], 'true'); + assert.equal(outputs['is-python-code'], 'true'); +}); + +test('classifies template changes', () => { + const outputs = outputsFor(['templates/consumer-repo/.github/workflows/pr-00-gate.yml']); + assert.equal(outputs['is-template-change'], 'true'); + assert.equal(outputs['is-workflow-change'], 'false'); +}); + +test('classifies test-only changes when every path is a test', () => { + const outputs = outputsFor([ + 'tests/workflows/test_gate.py', + '.github/scripts/__tests__/path-classifier.test.js', + ]); + assert.equal(outputs['is-test-only'], 'true'); + assert.equal(outputs['is-python-code'], 'true'); +}); + +test('mixed docs and code changes are not docs-only or test-only', () => { + const outputs = outputsFor(['docs/usage.md', 'scripts/sync_dev_dependencies.py']); + assert.equal(outputs['is-docs-only'], 'false'); + assert.equal(outputs['is-test-only'], 'false'); + assert.equal(outputs['is-python-code'], 'true'); + assert.equal(outputs['is-security-relevant'], 'true'); +}); + +test('empty diff does not enable categories', () => { + const outputs = outputsFor([]); + assert.equal(outputs['is-docs-only'], 'false'); + assert.equal(outputs['is-python-code'], 'false'); + assert.equal(outputs['is-workflow-change'], 'false'); + assert.equal(outputs['is-security-relevant'], 'false'); + assert.equal(outputs['is-template-change'], 'false'); + assert.equal(outputs['is-test-only'], 'false'); + assert.equal(outputs['affected-consumers'], '[]'); +}); + +test('force-full override enables every category output', () => { + const outputs = outputsFor(['README.md'], { forceFull: true }); + assert.equal(outputs['is-docs-only'], 'true'); + assert.equal(outputs['is-python-code'], 'true'); + assert.equal(outputs['is-workflow-change'], 'true'); + assert.equal(outputs['is-security-relevant'], 'true'); + assert.equal(outputs['is-template-change'], 'true'); + assert.equal(outputs['is-test-only'], 'true'); +}); + +test('parses classification YAML config', () => { + const parsed = parseClassificationConfig(` +categories: + docs-only: + require-all: true + paths: + - docs/** + - "*.md" + python-code: + require-all: false + paths: ["**/*.py", pyproject.toml] +`); + assert.deepEqual(parsed.categories['docs-only'], { + requireAll: true, + paths: ['docs/**', '*.md'], + }); + assert.deepEqual(parsed.categories['python-code'], { + requireAll: false, + paths: ['**/*.py', 'pyproject.toml'], + }); +}); diff --git a/.github/sync-manifest.yml b/.github/sync-manifest.yml index 7076ef6c1..9475d69a4 100644 --- a/.github/sync-manifest.yml +++ b/.github/sync-manifest.yml @@ -567,6 +567,9 @@ templates: - source: .github/PULL_REQUEST_TEMPLATE.md description: "Pull request template with Workflow Source fields for direct GitHub and non-issue work" + - source: .github/path-classification.yml + description: "Path classification config used by the path-classifier composite action" + # Actions required by reusable workflows actions: - source: .github/actions/setup-api-client/ @@ -577,6 +580,10 @@ actions: is_directory: true description: "Export load balancer tokens action - required by reusable-10-ci-python.yml (deprecated, use setup-api-client)" + - source: .github/actions/path-classifier/ + is_directory: true + description: "Classifies changed PR paths for path-aware expensive job gating" + # Configuration files for LLM providers and workflows llm_config: - source: config/llm_slots.json diff --git a/.github/workflows/maint-68-sync-consumer-repos.yml b/.github/workflows/maint-68-sync-consumer-repos.yml index 92fc246e3..d9e0404ca 100644 --- a/.github/workflows/maint-68-sync-consumer-repos.yml +++ b/.github/workflows/maint-68-sync-consumer-repos.yml @@ -23,6 +23,8 @@ on: - '.github/codex/**' - '.github/actions/setup-api-client/**' - '.github/actions/export-load-balancer-tokens/**' + - '.github/actions/path-classifier/**' + - '.github/path-classification.yml' - '.github/copilot-instructions.md' - '.github/copilot-skills/**' - '.github/dependabot.yml' diff --git a/.github/workflows/selftest-ci.yml b/.github/workflows/selftest-ci.yml index 97e6db62d..c901703b9 100644 --- a/.github/workflows/selftest-ci.yml +++ b/.github/workflows/selftest-ci.yml @@ -9,6 +9,7 @@ on: permissions: contents: read + pull-requests: read concurrency: group: selftest-ci-${{ github.repository }}-${{ github.ref }} @@ -28,6 +29,12 @@ jobs: - name: Run JavaScript tests run: node --test .github/scripts/__tests__/*.test.js + - name: Replay path classifier against recent merged PRs + env: + GH_TOKEN: ${{ github.token }} + PATH_CLASSIFIER_REPLAY: '1' + run: node --test .github/scripts/__tests__/path-classifier-replay.test.js + test-python: name: Python Tests runs-on: ubuntu-latest diff --git a/templates/consumer-repo/.github/actions/path-classifier/action.yml b/templates/consumer-repo/.github/actions/path-classifier/action.yml new file mode 100644 index 000000000..ce895316e --- /dev/null +++ b/templates/consumer-repo/.github/actions/path-classifier/action.yml @@ -0,0 +1,54 @@ +name: Path Classifier +description: Classify changed PR paths into workflow relevance categories. + +inputs: + base-ref: + description: Base ref or SHA to diff against. Defaults to PR base or push before SHA. + required: false + config-path: + description: Path classification config file. + required: false + default: .github/path-classification.yml + force-full: + description: Set true to force every category output to true. + required: false + default: 'false' + +outputs: + is-docs-only: + description: True when every changed path matches the docs-only category. + value: ${{ steps.classify.outputs.is-docs-only }} + is-python-code: + description: True when any changed path is Python-code relevant. + value: ${{ steps.classify.outputs.is-python-code }} + is-workflow-change: + description: True when any changed path affects GitHub workflows or actions. + value: ${{ steps.classify.outputs.is-workflow-change }} + is-security-relevant: + description: True when any changed path is security-scan relevant. + value: ${{ steps.classify.outputs.is-security-relevant }} + is-template-change: + description: True when any changed path affects consumer templates. + value: ${{ steps.classify.outputs.is-template-change }} + is-test-only: + description: True when every changed path matches the test-only category. + value: ${{ steps.classify.outputs.is-test-only }} + affected-consumers: + description: JSON list of affected consumers. Reserved for Wave 2. + value: ${{ steps.classify.outputs.affected-consumers }} + classification-rationale: + description: Human-readable classification rationale. + value: ${{ steps.classify.outputs.classification-rationale }} + +runs: + using: composite + steps: + - name: Classify changed paths + id: classify + shell: bash + env: + INPUT_BASE_REF: ${{ inputs.base-ref }} + INPUT_CONFIG_PATH: ${{ inputs.config-path }} + INPUT_FORCE_FULL: ${{ inputs.force-full }} + GITHUB_CONTEXT_JSON: ${{ toJSON(github) }} + run: node "$GITHUB_ACTION_PATH/classify.js" diff --git a/templates/consumer-repo/.github/actions/path-classifier/classify.js b/templates/consumer-repo/.github/actions/path-classifier/classify.js new file mode 100644 index 000000000..dbc081a0f --- /dev/null +++ b/templates/consumer-repo/.github/actions/path-classifier/classify.js @@ -0,0 +1,348 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const OUTPUT_NAMES = { + 'docs-only': 'is-docs-only', + 'python-code': 'is-python-code', + 'workflow-change': 'is-workflow-change', + 'security-relevant': 'is-security-relevant', + 'template-change': 'is-template-change', + 'test-only': 'is-test-only', +}; + +const DEFAULT_CATEGORIES = { + 'docs-only': { paths: ['docs/**', '*.md', 'README.md'], requireAll: true }, + 'python-code': { paths: ['**/*.py', 'pyproject.toml', 'requirements*.txt'], requireAll: false }, + 'workflow-change': { paths: ['.github/workflows/**', '.github/actions/**'], requireAll: false }, + 'security-relevant': { + paths: ['scripts/**', 'tools/**', '.github/workflows/**', 'pyproject.toml'], + requireAll: false, + }, + 'template-change': { paths: ['templates/**'], requireAll: false }, + 'test-only': { paths: ['tests/**', '**/test_*.py', '**/*.test.js'], requireAll: true }, +}; + +function normalizePath(value) { + return String(value || '').replace(/\\/g, '/').replace(/^\.\/+/, ''); +} + +function escapeRegExp(value) { + return value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&'); +} + +function globToRegExp(glob) { + const pattern = normalizePath(glob); + let out = '^'; + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern[index]; + const next = pattern[index + 1]; + if (char === '*') { + if (next === '*') { + const after = pattern[index + 2]; + if (after === '/') { + out += '(?:.*/)?'; + index += 2; + } else { + out += '.*'; + index += 1; + } + } else { + out += '[^/]*'; + } + } else if (char === '?') { + out += '[^/]'; + } else { + out += escapeRegExp(char); + } + } + out += '$'; + return new RegExp(out); +} + +function matchesAny(filePath, patterns) { + const normalized = normalizePath(filePath); + return patterns.some((pattern) => globToRegExp(pattern).test(normalized)); +} + +function parseScalar(value) { + const trimmed = String(value || '').trim(); + if (trimmed === 'true') { + return true; + } + if (trimmed === 'false') { + return false; + } + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function parseInlineList(value) { + const trimmed = String(value || '').trim(); + if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) { + return null; + } + const body = trimmed.slice(1, -1).trim(); + if (!body) { + return []; + } + return body.split(',').map((entry) => parseScalar(entry)); +} + +function parseClassificationConfig(raw) { + const categories = {}; + const lines = String(raw || '').split(/\r?\n/); + let section = null; + let category = null; + let listKey = null; + + for (const line of lines) { + const withoutComment = line.replace(/\s+#.*$/, ''); + if (!withoutComment.trim()) { + continue; + } + const indent = withoutComment.match(/^ */)[0].length; + const trimmed = withoutComment.trim(); + + if (indent === 0 && trimmed.endsWith(':')) { + section = trimmed.slice(0, -1); + category = null; + listKey = null; + continue; + } + + if (section !== 'categories') { + continue; + } + + if (indent === 2 && trimmed.endsWith(':')) { + category = trimmed.slice(0, -1); + categories[category] = categories[category] || {}; + listKey = null; + continue; + } + + if (!category) { + continue; + } + + if (indent === 4 && trimmed.includes(':')) { + const [key, ...rest] = trimmed.split(':'); + const value = rest.join(':').trim(); + const normalizedKey = key === 'require-all' ? 'requireAll' : key; + if (!value) { + categories[category][normalizedKey] = []; + listKey = normalizedKey; + continue; + } + const inlineList = parseInlineList(value); + categories[category][normalizedKey] = inlineList === null ? parseScalar(value) : inlineList; + listKey = null; + continue; + } + + if (indent >= 6 && listKey && trimmed.startsWith('- ')) { + categories[category][listKey].push(parseScalar(trimmed.slice(2))); + } + } + + return { categories }; +} + +function loadConfig(configPath) { + const resolved = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd(), configPath); + if (!fs.existsSync(resolved)) { + return { categories: DEFAULT_CATEGORIES, configPath: resolved, usedDefault: true }; + } + const parsed = parseClassificationConfig(fs.readFileSync(resolved, 'utf8')); + const categories = {}; + for (const [name, fallback] of Object.entries(DEFAULT_CATEGORIES)) { + const configured = parsed.categories[name] || {}; + categories[name] = { + paths: Array.isArray(configured.paths) ? configured.paths : fallback.paths, + requireAll: + typeof configured.requireAll === 'boolean' ? configured.requireAll : fallback.requireAll, + }; + } + return { categories, configPath: resolved, usedDefault: false }; +} + +function parseGithubContext() { + try { + return JSON.parse(process.env.GITHUB_CONTEXT_JSON || '{}'); + } catch { + return {}; + } +} + +function runGit(args) { + return execFileSync('git', args, { + cwd: process.env.GITHUB_WORKSPACE || process.cwd(), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function tryGit(args) { + try { + return runGit(args); + } catch { + return ''; + } +} + +function resolveBaseRef(inputBaseRef, githubContext) { + if (inputBaseRef) { + return inputBaseRef; + } + if (githubContext.event_name === 'pull_request' && githubContext.base_ref) { + return `origin/${githubContext.base_ref}`; + } + const event = githubContext.event || {}; + if (event.before && !/^0+$/.test(event.before)) { + return event.before; + } + return ''; +} + +function fetchBaseRef(baseRef, githubContext) { + if (!baseRef || !baseRef.startsWith('origin/')) { + return; + } + const branch = baseRef.slice('origin/'.length); + tryGit(['fetch', '--no-tags', '--depth=1', 'origin', branch]); + const prBaseSha = githubContext.event?.pull_request?.base?.sha; + if (prBaseSha) { + tryGit(['fetch', '--no-tags', '--depth=1', 'origin', prBaseSha]); + } +} + +function listChangedFiles({ baseRef, githubContext } = {}) { + const envFiles = process.env.PATH_CLASSIFIER_FILES_JSON; + if (envFiles) { + const parsed = JSON.parse(envFiles); + if (!Array.isArray(parsed)) { + throw new Error('PATH_CLASSIFIER_FILES_JSON must be a JSON array'); + } + return parsed.map(normalizePath).filter(Boolean); + } + + fetchBaseRef(baseRef, githubContext); + const head = githubContext.sha || 'HEAD'; + const ranges = []; + if (baseRef) { + ranges.push(`${baseRef}...${head}`); + ranges.push(`${baseRef}..${head}`); + } + const prBaseSha = githubContext.event?.pull_request?.base?.sha; + if (prBaseSha) { + ranges.push(`${prBaseSha}...${head}`); + ranges.push(`${prBaseSha}..${head}`); + } + + for (const range of ranges) { + const output = tryGit(['diff', '--name-only', range]); + if (output) { + return output.split(/\r?\n/).map(normalizePath).filter(Boolean); + } + } + return []; +} + +function classifyFiles(files, config, { forceFull = false, conservativeFull = false } = {}) { + const changedFiles = Array.from(new Set((files || []).map(normalizePath).filter(Boolean))); + const outputs = {}; + const matched = {}; + + for (const [category, rule] of Object.entries(config.categories)) { + const outputName = OUTPUT_NAMES[category] || `is-${category}`; + const patterns = Array.isArray(rule.paths) ? rule.paths : []; + const matches = changedFiles.filter((filePath) => matchesAny(filePath, patterns)); + let enabled; + if (forceFull || conservativeFull) { + enabled = true; + } else if (changedFiles.length === 0) { + enabled = false; + } else if (rule.requireAll) { + enabled = matches.length === changedFiles.length; + } else { + enabled = matches.length > 0; + } + outputs[outputName] = enabled ? 'true' : 'false'; + matched[category] = matches; + } + + outputs['affected-consumers'] = '[]'; + const trueOutputs = Object.entries(outputs) + .filter(([key, value]) => key.startsWith('is-') && value === 'true') + .map(([key]) => key.replace(/^is-/, '')); + const mode = forceFull ? 'force-full' : conservativeFull ? 'conservative-full' : 'classified'; + outputs['classification-rationale'] = + `${mode}: ${changedFiles.length} changed file(s); ` + + (trueOutputs.length ? `matched ${trueOutputs.join(', ')}` : 'no categories matched'); + + return { outputs, changedFiles, matched }; +} + +function writeOutputs(outputs) { + const outputPath = process.env.GITHUB_OUTPUT; + for (const [key, value] of Object.entries(outputs)) { + console.log(`${key}=${value}`); + } + if (!outputPath) { + return; + } + const lines = []; + for (const [key, value] of Object.entries(outputs)) { + if (String(value).includes('\n')) { + lines.push(`${key}< Date: Mon, 4 May 2026 11:57:11 -0500 Subject: [PATCH 2/4] feat: wire path classifier in observation mode --- .../health-44-gate-branch-protection.yml | 18 +++++++++++++++++ .github/workflows/health-50-security-scan.yml | 18 +++++++++++++++++ .github/workflows/pr-00-gate.yml | 17 ++++++++++++++++ .github/workflows/selftest-ci.yml | 20 +++++++++++++++++++ .../.github/workflows/pr-00-gate.yml | 17 ++++++++++++++++ 5 files changed, 90 insertions(+) diff --git a/.github/workflows/health-44-gate-branch-protection.yml b/.github/workflows/health-44-gate-branch-protection.yml index 9b77cc35c..574ad256c 100644 --- a/.github/workflows/health-44-gate-branch-protection.yml +++ b/.github/workflows/health-44-gate-branch-protection.yml @@ -24,7 +24,25 @@ concurrency: cancel-in-progress: true jobs: + classify: + name: classify changed paths + runs-on: ubuntu-latest + outputs: + is_docs_only: ${{ steps.classify.outputs.is-docs-only || 'false' }} + classification_rationale: >- + ${{ steps.classify.outputs.classification-rationale || 'path classifier unavailable' }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Classify changed paths + id: classify + uses: ./.github/actions/path-classifier + with: + force-full: 'true' + enforce: + needs: classify runs-on: ubuntu-latest timeout-minutes: 20 steps: diff --git a/.github/workflows/health-50-security-scan.yml b/.github/workflows/health-50-security-scan.yml index 02f91934e..d41f03a19 100644 --- a/.github/workflows/health-50-security-scan.yml +++ b/.github/workflows/health-50-security-scan.yml @@ -16,8 +16,26 @@ concurrency: cancel-in-progress: true jobs: + classify: + name: classify changed paths + runs-on: ubuntu-latest + outputs: + is_security_relevant: ${{ steps.classify.outputs.is-security-relevant || 'true' }} + classification_rationale: >- + ${{ steps.classify.outputs.classification-rationale || 'path classifier unavailable' }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Classify changed paths + id: classify + uses: ./.github/actions/path-classifier + with: + force-full: 'true' + codeql: name: CodeQL + needs: classify runs-on: ubuntu-latest permissions: actions: read diff --git a/.github/workflows/pr-00-gate.yml b/.github/workflows/pr-00-gate.yml index be54447cd..064d1cc1a 100644 --- a/.github/workflows/pr-00-gate.yml +++ b/.github/workflows/pr-00-gate.yml @@ -30,6 +30,15 @@ jobs: pytest_markers: >- ${{ steps.python_ci_toggles.outputs.pytest_markers || 'not quarantine and not slow' }} workflow_changed: ${{ steps.diff.outputs.workflow_changed || 'false' }} + is_docs_only: ${{ steps.classify.outputs.is-docs-only || 'false' }} + is_python_code: ${{ steps.classify.outputs.is-python-code || 'true' }} + is_workflow_change: ${{ steps.classify.outputs.is-workflow-change || 'false' }} + is_security_relevant: ${{ steps.classify.outputs.is-security-relevant || 'true' }} + is_template_change: ${{ steps.classify.outputs.is-template-change || 'false' }} + is_test_only: ${{ steps.classify.outputs.is-test-only || 'false' }} + affected_consumers: ${{ steps.classify.outputs.affected-consumers || '[]' }} + classification_rationale: >- + ${{ steps.classify.outputs.classification-rationale || 'path classifier unavailable' }} steps: # Mint GitHub App token early to use for API calls (avoids rate limits) - name: Mint GitHub App Token @@ -49,8 +58,16 @@ jobs: repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} ref: ${{ github.event.pull_request.head.sha || github.sha }} sparse-checkout: | + .github/actions/path-classifier + .github/path-classification.yml .github/scripts sparse-checkout-cone-mode: false + - name: Classify changed paths + id: classify + uses: ./.github/actions/path-classifier + with: + force-full: 'true' + - name: Detect changes via API id: diff uses: actions/github-script@v9 diff --git a/.github/workflows/selftest-ci.yml b/.github/workflows/selftest-ci.yml index c901703b9..61fa05bd6 100644 --- a/.github/workflows/selftest-ci.yml +++ b/.github/workflows/selftest-ci.yml @@ -16,8 +16,26 @@ concurrency: cancel-in-progress: true jobs: + classify: + name: classify changed paths + runs-on: ubuntu-latest + outputs: + is_docs_only: ${{ steps.classify.outputs.is-docs-only || 'false' }} + classification_rationale: >- + ${{ steps.classify.outputs.classification-rationale || 'path classifier unavailable' }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Classify changed paths + id: classify + uses: ./.github/actions/path-classifier + with: + force-full: 'true' + test-javascript: name: JavaScript Tests + needs: classify runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -37,6 +55,7 @@ jobs: test-python: name: Python Tests + needs: classify runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -57,6 +76,7 @@ jobs: lint-and-validate: name: Lint, Format & YAML Validation + needs: classify runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/templates/consumer-repo/.github/workflows/pr-00-gate.yml b/templates/consumer-repo/.github/workflows/pr-00-gate.yml index eb46da199..b7ff39fa5 100644 --- a/templates/consumer-repo/.github/workflows/pr-00-gate.yml +++ b/templates/consumer-repo/.github/workflows/pr-00-gate.yml @@ -30,6 +30,15 @@ jobs: pytest_markers: >- ${{ steps.python_ci_toggles.outputs.pytest_markers || 'not quarantine and not slow' }} workflow_changed: ${{ steps.diff.outputs.workflow_changed || 'false' }} + is_docs_only: ${{ steps.classify.outputs.is-docs-only || 'false' }} + is_python_code: ${{ steps.classify.outputs.is-python-code || 'true' }} + is_workflow_change: ${{ steps.classify.outputs.is-workflow-change || 'false' }} + is_security_relevant: ${{ steps.classify.outputs.is-security-relevant || 'true' }} + is_template_change: ${{ steps.classify.outputs.is-template-change || 'false' }} + is_test_only: ${{ steps.classify.outputs.is-test-only || 'false' }} + affected_consumers: ${{ steps.classify.outputs.affected-consumers || '[]' }} + classification_rationale: >- + ${{ steps.classify.outputs.classification-rationale || 'path classifier unavailable' }} steps: # Mint GitHub App token early to use for API calls (avoids rate limits) - name: Mint GitHub App Token @@ -49,8 +58,16 @@ jobs: repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} ref: ${{ github.event.pull_request.head.sha || github.sha }} sparse-checkout: | + .github/actions/path-classifier + .github/path-classification.yml .github/scripts sparse-checkout-cone-mode: false + - name: Classify changed paths + id: classify + uses: ./.github/actions/path-classifier + with: + force-full: 'true' + - name: Detect changes via API id: diff uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 From 754128bd3f295e728538588396bbcb057aec3e92 Mon Sep 17 00:00:00 2001 From: Tim Stranske Date: Mon, 4 May 2026 11:58:51 -0500 Subject: [PATCH 3/4] feat: enable path classifier gates --- .../health-44-gate-branch-protection.yml | 3 +- .github/workflows/health-50-security-scan.yml | 3 +- .github/workflows/pr-00-gate.yml | 32 +++++++++++++++---- .github/workflows/selftest-ci.yml | 5 ++- .../.github/workflows/pr-00-gate.yml | 32 +++++++++++++++---- 5 files changed, 60 insertions(+), 15 deletions(-) diff --git a/.github/workflows/health-44-gate-branch-protection.yml b/.github/workflows/health-44-gate-branch-protection.yml index 574ad256c..b905d009d 100644 --- a/.github/workflows/health-44-gate-branch-protection.yml +++ b/.github/workflows/health-44-gate-branch-protection.yml @@ -39,10 +39,11 @@ jobs: id: classify uses: ./.github/actions/path-classifier with: - force-full: 'true' + force-full: ${{ github.event_name != 'pull_request' }} enforce: needs: classify + if: ${{ github.event_name != 'pull_request' || needs.classify.outputs.is_docs_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 20 steps: diff --git a/.github/workflows/health-50-security-scan.yml b/.github/workflows/health-50-security-scan.yml index d41f03a19..20ece88d7 100644 --- a/.github/workflows/health-50-security-scan.yml +++ b/.github/workflows/health-50-security-scan.yml @@ -31,11 +31,12 @@ jobs: id: classify uses: ./.github/actions/path-classifier with: - force-full: 'true' + force-full: ${{ github.event_name == 'schedule' }} codeql: name: CodeQL needs: classify + if: ${{ needs.classify.outputs.is_security_relevant == 'true' }} runs-on: ubuntu-latest permissions: actions: read diff --git a/.github/workflows/pr-00-gate.yml b/.github/workflows/pr-00-gate.yml index 064d1cc1a..4df6fa3a7 100644 --- a/.github/workflows/pr-00-gate.yml +++ b/.github/workflows/pr-00-gate.yml @@ -17,9 +17,24 @@ jobs: name: detect changed files runs-on: ubuntu-latest outputs: - doc_only: ${{ steps.diff.outputs.doc_only || 'false' }} - run_core: ${{ steps.diff.outputs.run_core || 'true' }} - reason: ${{ steps.diff.outputs.reason || 'code_changes' }} + doc_only: >- + ${{ github.event_name == 'pull_request' && steps.classify.outputs.is-docs-only == 'true' }} + run_core: >- + ${{ + github.event_name == 'pull_request' && + steps.classify.outputs.is-docs-only == 'true' && + 'false' || + steps.diff.outputs.run_core || + 'true' + }} + reason: >- + ${{ + github.event_name == 'pull_request' && + steps.classify.outputs.is-docs-only == 'true' && + 'docs_only' || + steps.diff.outputs.reason || + 'code_changes' + }} docker_changed: >- ${{ steps.diff.outputs.docker_changed || 'false' }} lint: ${{ steps.python_ci_toggles.outputs.lint || 'true' }} @@ -29,7 +44,7 @@ jobs: cache: ${{ steps.python_ci_toggles.outputs.cache || 'true' }} pytest_markers: >- ${{ steps.python_ci_toggles.outputs.pytest_markers || 'not quarantine and not slow' }} - workflow_changed: ${{ steps.diff.outputs.workflow_changed || 'false' }} + workflow_changed: ${{ steps.classify.outputs.is-workflow-change || steps.diff.outputs.workflow_changed || 'false' }} is_docs_only: ${{ steps.classify.outputs.is-docs-only || 'false' }} is_python_code: ${{ steps.classify.outputs.is-python-code || 'true' }} is_workflow_change: ${{ steps.classify.outputs.is-workflow-change || 'false' }} @@ -66,7 +81,7 @@ jobs: id: classify uses: ./.github/actions/path-classifier with: - force-full: 'true' + force-full: ${{ github.event_name == 'workflow_dispatch' }} - name: Detect changes via API id: diff @@ -142,7 +157,12 @@ jobs: needs: - detect - environment-gate - if: ${{ needs.detect.outputs.doc_only != 'true' && needs.detect.outputs.run_core == 'true' }} + if: >- + ${{ + needs.detect.outputs.doc_only != 'true' && + needs.detect.outputs.is_python_code == 'true' && + needs.detect.outputs.run_core == 'true' + }} uses: ./.github/workflows/reusable-10-ci-python.yml secrets: inherit permissions: diff --git a/.github/workflows/selftest-ci.yml b/.github/workflows/selftest-ci.yml index 61fa05bd6..9b76ef98b 100644 --- a/.github/workflows/selftest-ci.yml +++ b/.github/workflows/selftest-ci.yml @@ -31,11 +31,12 @@ jobs: id: classify uses: ./.github/actions/path-classifier with: - force-full: 'true' + force-full: ${{ github.event_name == 'workflow_dispatch' }} test-javascript: name: JavaScript Tests needs: classify + if: ${{ github.event_name == 'workflow_dispatch' || needs.classify.outputs.is_docs_only != 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -56,6 +57,7 @@ jobs: test-python: name: Python Tests needs: classify + if: ${{ github.event_name == 'workflow_dispatch' || needs.classify.outputs.is_docs_only != 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -77,6 +79,7 @@ jobs: lint-and-validate: name: Lint, Format & YAML Validation needs: classify + if: ${{ github.event_name == 'workflow_dispatch' || needs.classify.outputs.is_docs_only != 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/templates/consumer-repo/.github/workflows/pr-00-gate.yml b/templates/consumer-repo/.github/workflows/pr-00-gate.yml index b7ff39fa5..c96d57754 100644 --- a/templates/consumer-repo/.github/workflows/pr-00-gate.yml +++ b/templates/consumer-repo/.github/workflows/pr-00-gate.yml @@ -17,9 +17,24 @@ jobs: name: detect changed files runs-on: ubuntu-latest outputs: - doc_only: ${{ steps.diff.outputs.doc_only || 'false' }} - run_core: ${{ steps.diff.outputs.run_core || 'true' }} - reason: ${{ steps.diff.outputs.reason || 'code_changes' }} + doc_only: >- + ${{ github.event_name == 'pull_request' && steps.classify.outputs.is-docs-only == 'true' }} + run_core: >- + ${{ + github.event_name == 'pull_request' && + steps.classify.outputs.is-docs-only == 'true' && + 'false' || + steps.diff.outputs.run_core || + 'true' + }} + reason: >- + ${{ + github.event_name == 'pull_request' && + steps.classify.outputs.is-docs-only == 'true' && + 'docs_only' || + steps.diff.outputs.reason || + 'code_changes' + }} docker_changed: >- ${{ steps.diff.outputs.docker_changed || 'false' }} lint: ${{ steps.python_ci_toggles.outputs.lint || 'true' }} @@ -29,7 +44,7 @@ jobs: cache: ${{ steps.python_ci_toggles.outputs.cache || 'true' }} pytest_markers: >- ${{ steps.python_ci_toggles.outputs.pytest_markers || 'not quarantine and not slow' }} - workflow_changed: ${{ steps.diff.outputs.workflow_changed || 'false' }} + workflow_changed: ${{ steps.classify.outputs.is-workflow-change || steps.diff.outputs.workflow_changed || 'false' }} is_docs_only: ${{ steps.classify.outputs.is-docs-only || 'false' }} is_python_code: ${{ steps.classify.outputs.is-python-code || 'true' }} is_workflow_change: ${{ steps.classify.outputs.is-workflow-change || 'false' }} @@ -66,7 +81,7 @@ jobs: id: classify uses: ./.github/actions/path-classifier with: - force-full: 'true' + force-full: ${{ github.event_name == 'workflow_dispatch' }} - name: Detect changes via API id: diff @@ -142,7 +157,12 @@ jobs: needs: - detect - environment-gate - if: ${{ needs.detect.outputs.doc_only != 'true' && needs.detect.outputs.run_core == 'true' }} + if: >- + ${{ + needs.detect.outputs.doc_only != 'true' && + needs.detect.outputs.is_python_code == 'true' && + needs.detect.outputs.run_core == 'true' + }} uses: ./.github/workflows/reusable-10-ci-python.yml secrets: inherit permissions: From 8105b51af571c0a6a931b0ba31f91f2904b775a8 Mon Sep 17 00:00:00 2001 From: Tim Stranske Date: Mon, 4 May 2026 12:04:45 -0500 Subject: [PATCH 4/4] fix: accept path-classified python skips --- .github/scripts/gate_summary.py | 10 +++++++-- .github/workflows/pr-00-gate.yml | 1 + .../.github/scripts/gate_summary.py | 10 +++++++-- .../.github/workflows/pr-00-gate.yml | 1 + .../github_scripts/test_gate_summary.py | 21 +++++++++++++++++++ 5 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.github/scripts/gate_summary.py b/.github/scripts/gate_summary.py index 8a1a5bd18..50379e2a2 100644 --- a/.github/scripts/gate_summary.py +++ b/.github/scripts/gate_summary.py @@ -19,6 +19,7 @@ class SummaryContext: artifacts_root: Path summary_path: Path | None output_path: Path | None + python_required: bool = True @dataclass(slots=True) @@ -322,8 +323,11 @@ def summarize(context: SummaryContext) -> SummaryResult: failure_checks: tuple[str, ...] = () format_failure = False - # Python CI skipped is OK if run_core is false (doc/workflow-only changes) - if python_result == "cancelled": + # Python CI skipped is OK when path classification says no Python-relevant + # files changed. + if not context.python_required and python_result == "skipped": + lines.append("- Python CI skipped: no Python-code changes detected.") + elif python_result == "cancelled": state = "pending" description = "Python CI cancelled; waiting for rerun." elif python_result not in ("success", "skipped") or ( @@ -377,6 +381,7 @@ def build_context() -> SummaryContext: python_result = os.environ.get("PYTHON_RESULT") or "skipped" docker_result = os.environ.get("DOCKER_RESULT") or "skipped" docker_changed = _normalize(os.environ.get("DOCKER_CHANGED"), "false") == "true" + python_required = _normalize(os.environ.get("PYTHON_REQUIRED"), "true") == "true" artifacts_root = Path(os.environ.get("GATE_ARTIFACTS_ROOT", "gate_artifacts")) summary_path = _resolve_path("GITHUB_STEP_SUMMARY") output_path = _resolve_path("GITHUB_OUTPUT") @@ -391,6 +396,7 @@ def build_context() -> SummaryContext: artifacts_root=artifacts_root, summary_path=summary_path, output_path=output_path, + python_required=python_required, ) diff --git a/.github/workflows/pr-00-gate.yml b/.github/workflows/pr-00-gate.yml index 4df6fa3a7..ef729f64c 100644 --- a/.github/workflows/pr-00-gate.yml +++ b/.github/workflows/pr-00-gate.yml @@ -507,6 +507,7 @@ jobs: RUN_CORE: ${{ needs.detect.outputs.run_core || 'true' }} REASON: ${{ needs.detect.outputs.reason }} PYTHON_RESULT: ${{ needs.python-ci.result || 'skipped' }} + PYTHON_REQUIRED: ${{ needs.detect.outputs.is_python_code || 'true' }} DOCKER_RESULT: ${{ needs.docker-smoke.result || 'skipped' }} DOCKER_CHANGED: ${{ needs.detect.outputs.docker_changed || 'false' }} COVERAGE_ENABLED: ${{ needs.detect.outputs.coverage || 'true' }} diff --git a/templates/consumer-repo/.github/scripts/gate_summary.py b/templates/consumer-repo/.github/scripts/gate_summary.py index 8a1a5bd18..50379e2a2 100644 --- a/templates/consumer-repo/.github/scripts/gate_summary.py +++ b/templates/consumer-repo/.github/scripts/gate_summary.py @@ -19,6 +19,7 @@ class SummaryContext: artifacts_root: Path summary_path: Path | None output_path: Path | None + python_required: bool = True @dataclass(slots=True) @@ -322,8 +323,11 @@ def summarize(context: SummaryContext) -> SummaryResult: failure_checks: tuple[str, ...] = () format_failure = False - # Python CI skipped is OK if run_core is false (doc/workflow-only changes) - if python_result == "cancelled": + # Python CI skipped is OK when path classification says no Python-relevant + # files changed. + if not context.python_required and python_result == "skipped": + lines.append("- Python CI skipped: no Python-code changes detected.") + elif python_result == "cancelled": state = "pending" description = "Python CI cancelled; waiting for rerun." elif python_result not in ("success", "skipped") or ( @@ -377,6 +381,7 @@ def build_context() -> SummaryContext: python_result = os.environ.get("PYTHON_RESULT") or "skipped" docker_result = os.environ.get("DOCKER_RESULT") or "skipped" docker_changed = _normalize(os.environ.get("DOCKER_CHANGED"), "false") == "true" + python_required = _normalize(os.environ.get("PYTHON_REQUIRED"), "true") == "true" artifacts_root = Path(os.environ.get("GATE_ARTIFACTS_ROOT", "gate_artifacts")) summary_path = _resolve_path("GITHUB_STEP_SUMMARY") output_path = _resolve_path("GITHUB_OUTPUT") @@ -391,6 +396,7 @@ def build_context() -> SummaryContext: artifacts_root=artifacts_root, summary_path=summary_path, output_path=output_path, + python_required=python_required, ) diff --git a/templates/consumer-repo/.github/workflows/pr-00-gate.yml b/templates/consumer-repo/.github/workflows/pr-00-gate.yml index c96d57754..b7b56db53 100644 --- a/templates/consumer-repo/.github/workflows/pr-00-gate.yml +++ b/templates/consumer-repo/.github/workflows/pr-00-gate.yml @@ -506,6 +506,7 @@ jobs: RUN_CORE: ${{ needs.detect.outputs.run_core || 'true' }} REASON: ${{ needs.detect.outputs.reason }} PYTHON_RESULT: ${{ needs.python-ci.result || 'skipped' }} + PYTHON_REQUIRED: ${{ needs.detect.outputs.is_python_code || 'true' }} DOCKER_RESULT: ${{ needs.docker-smoke.result || 'skipped' }} DOCKER_CHANGED: ${{ needs.detect.outputs.docker_changed || 'false' }} COVERAGE_ENABLED: ${{ needs.detect.outputs.coverage || 'true' }} diff --git a/tests/workflows/github_scripts/test_gate_summary.py b/tests/workflows/github_scripts/test_gate_summary.py index 9bd7794d7..1c29e951a 100644 --- a/tests/workflows/github_scripts/test_gate_summary.py +++ b/tests/workflows/github_scripts/test_gate_summary.py @@ -313,6 +313,25 @@ def test_summarize_skipped_python_without_records_is_pending(tmp_path: Path) -> assert "Python CI skipped; waiting for rerun." in result.description +def test_summarize_accepts_path_classified_python_skip(tmp_path: Path) -> None: + context = gate_summary.SummaryContext( + doc_only=False, + run_core=True, + reason="", + python_result="skipped", + docker_result="skipped", + docker_changed=False, + artifacts_root=tmp_path, + summary_path=None, + output_path=None, + python_required=False, + ) + + result = gate_summary.summarize(context) + assert result.state == "success" + assert "no Python-code changes detected" in "\n".join(result.lines) + + def test_summarize_handles_docker_failures(tmp_path: Path) -> None: write_summary(tmp_path, "3.12") context = gate_summary.SummaryContext( @@ -338,6 +357,7 @@ def test_build_context_reads_environment(monkeypatch: pytest.MonkeyPatch, tmp_pa monkeypatch.setenv("RUN_CORE", "False") monkeypatch.setenv("REASON", "workflow_only") monkeypatch.setenv("PYTHON_RESULT", "FAILURE") + monkeypatch.setenv("PYTHON_REQUIRED", "FALSE") monkeypatch.setenv("DOCKER_RESULT", "cancelled") monkeypatch.setenv("DOCKER_CHANGED", "TRUE") artifacts_root = tmp_path / "gate_artifacts" @@ -352,6 +372,7 @@ def test_build_context_reads_environment(monkeypatch: pytest.MonkeyPatch, tmp_pa assert context.run_core is False assert context.reason == "workflow_only" assert context.python_result == "FAILURE" + assert context.python_required is False assert context.docker_result == "cancelled" assert context.docker_changed is True assert context.artifacts_root == artifacts_root