test.yml had no paths filter and the ci-test-scope classifier treated docs/ and every .github/workflows/* as code_changed, so documentation edits and product-irrelevant automation tweaks still spun up the full Linux/Windows/macOS matrix. Narrow the heavy matrix to changes that can actually affect the product or the test pipeline. - ci-test-scope.cjs: drop docs/ from code_changed (docs-only -> full skip; the required-tests fan-in still reports green). Add src/ to code_changed (it was missing -> a source-only PR previously skipped all tests). Add INERT_WORKFLOWS allowlist + isInertCi() + an "inert CI" rule, and a product_changed output that gates the heavy test/coverage jobs. Fail-safe: any workflow not on the inert allowlist defaults to the full matrix. A module-load assertion throws if a PROTECTED_WORKFLOWS entry (test/install-smoke/mutation/security-scan/release) is ever added to the inert set, so a weakening edit fails CI loudly. - test.yml: keep the static 3-lane matrix (so the H1 shell-policy linter can still statically verify the Windows lane), gate test/coverage on product_changed, add a lightweight ubuntu-only test-inert job, and branch the required-tests fan-in on product_changed. - docs-required.yml: run docs-parity-live-registry (gated on docs/ changes) so pure-docs PRs still catch live-registry drift without the matrix. - tests: cover docs-only, inert-only, src/, pipeline, unknown-workflow fail-safe, mixed escalation, the code_changed=false -> no-lanes invariant, and protected- workflow tamper-evidence. Closes #764 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,16 +6,84 @@ const { existsSync, readdirSync, appendFileSync } = require('fs');
|
||||
|
||||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||||
|
||||
// Workflow files that are purely administrative / policy bots. Changes to these
|
||||
// files do NOT require the cross-platform test matrix — only a lightweight
|
||||
// ubuntu lane running workflow-lint tests is needed.
|
||||
// FAIL-SAFE: any .github/workflows/*.yml NOT listed here is treated as a
|
||||
// pipeline workflow and gets the full matrix. New workflow files default to full.
|
||||
const INERT_WORKFLOWS = new Set([
|
||||
'stale.yml',
|
||||
'branch-cleanup.yml',
|
||||
'branch-naming.yml',
|
||||
'auto-label-issues.yml',
|
||||
'auto-branch.yml',
|
||||
'auto-backmerge.yml',
|
||||
'close-draft-prs.yml',
|
||||
'dismiss-unauthorized-pr-approvals.yml',
|
||||
'pr-gate.yml',
|
||||
'pr-target-validator.yml',
|
||||
'pr-template-format.yml',
|
||||
'require-issue-link.yml',
|
||||
'changeset-required.yml',
|
||||
'docs-required.yml',
|
||||
'discord-changelog.yml',
|
||||
]);
|
||||
|
||||
// Workflows that gate merges, ship the product, or run security/cross-platform
|
||||
// suites — these must ALWAYS get the full pipeline treatment and can never be
|
||||
// added to INERT_WORKFLOWS. A module-load assertion enforces this so a mistaken
|
||||
// or malicious addition fails CI loudly in the `changes` job on every PR.
|
||||
const PROTECTED_WORKFLOWS = new Set([
|
||||
'test.yml',
|
||||
'install-smoke.yml',
|
||||
'mutation.yml',
|
||||
'security-scan.yml',
|
||||
'release.yml',
|
||||
]);
|
||||
for (const wf of PROTECTED_WORKFLOWS) {
|
||||
if (INERT_WORKFLOWS.has(wf)) {
|
||||
throw new Error(`ci-test-scope: protected workflow "${wf}" must not be in INERT_WORKFLOWS (it requires the full test matrix).`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the path is an inert (non-pipeline) workflow file.
|
||||
* Only `.github/workflows/<name>` where <name> is in INERT_WORKFLOWS qualifies.
|
||||
*/
|
||||
function isInertCi(filePath) {
|
||||
if (!filePath.startsWith('.github/workflows/')) return false;
|
||||
const name = filePath.slice('.github/workflows/'.length);
|
||||
// Must be a direct child (no further slashes) and in the allowlist.
|
||||
return !name.includes('/') && INERT_WORKFLOWS.has(name);
|
||||
}
|
||||
|
||||
// Tests shared by both the 'workflow automation' and 'inert CI' rules.
|
||||
const WORKFLOW_LINT_TESTS = [
|
||||
'tests/workflow-shell-pinning.test.cjs',
|
||||
'tests/pr-template-policy.test.cjs',
|
||||
'tests/lint-pr-check-project-dir.test.cjs',
|
||||
];
|
||||
|
||||
const RULES = [
|
||||
{
|
||||
name: 'workflow automation',
|
||||
match: path => path.startsWith('.github/workflows/') || path.startsWith('.github/rulesets/'),
|
||||
// Only NON-inert .github/workflows/* and all .github/rulesets/* trigger full matrix.
|
||||
// FAIL-SAFE: any .github/workflows/*.yml not in INERT_WORKFLOWS is treated as pipeline.
|
||||
match: filePath => (filePath.startsWith('.github/workflows/') && !isInertCi(filePath)) ||
|
||||
filePath.startsWith('.github/rulesets/'),
|
||||
fullMatrix: true,
|
||||
tests: [
|
||||
'tests/workflow-shell-pinning.test.cjs',
|
||||
...WORKFLOW_LINT_TESTS,
|
||||
'tests/release-tarball-smoke-workflow.test.cjs',
|
||||
'tests/lint-pr-check-project-dir.test.cjs',
|
||||
'tests/pr-template-policy.test.cjs',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'inert CI',
|
||||
match: filePath => isInertCi(filePath),
|
||||
fullMatrix: false,
|
||||
tests: [
|
||||
...WORKFLOW_LINT_TESTS,
|
||||
'tests/policy-lint-shallow-checkout.test.cjs',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -144,14 +212,6 @@ const RULES = [
|
||||
'tests/docs-parity-live-registry.test.cjs',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'docs content',
|
||||
match: path => path.startsWith('docs/'),
|
||||
fullMatrix: false,
|
||||
tests: [
|
||||
'tests/docs-parity-live-registry.test.cjs',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'configuration',
|
||||
match: path => ['config', 'configuration', 'model-catalog', 'model-profile'].some(k => path.includes(k)),
|
||||
@@ -251,16 +311,30 @@ function classify(files) {
|
||||
const targeted = new Set();
|
||||
const windows = new Set();
|
||||
const reasons = [];
|
||||
let codeChanged = false;
|
||||
let productOrPipelineChanged = false; // product/pipeline code (excludes docs)
|
||||
let inertCiChanged = false; // inert workflow files
|
||||
let fullMatrix = false;
|
||||
|
||||
for (const file of files) {
|
||||
if (['bin/', 'gsd-core/', 'agents/', 'commands/', 'docs/', 'hooks/', 'tests/', 'scripts/'].some(p => file.startsWith(p)) ||
|
||||
// Determine if this file is product/pipeline code.
|
||||
// docs/ and root-level .md files are intentionally excluded.
|
||||
if (
|
||||
['bin/', 'src/', 'gsd-core/', 'agents/', 'commands/', 'hooks/', 'tests/', 'scripts/'].some(p => file.startsWith(p)) ||
|
||||
file === 'package.json' || file === 'package-lock.json' ||
|
||||
(file.startsWith('tsconfig') && file.endsWith('.json')) ||
|
||||
file.startsWith('.github/workflows/') ||
|
||||
file.startsWith('.github/rulesets/')) {
|
||||
codeChanged = true;
|
||||
file.startsWith('.github/rulesets/')
|
||||
) {
|
||||
productOrPipelineChanged = true;
|
||||
}
|
||||
|
||||
// Non-inert .github/workflows/* are pipeline code → full matrix.
|
||||
if (file.startsWith('.github/workflows/') && !isInertCi(file)) {
|
||||
productOrPipelineChanged = true;
|
||||
}
|
||||
|
||||
// Inert workflow files set a lightweight signal.
|
||||
if (isInertCi(file)) {
|
||||
inertCiChanged = true;
|
||||
}
|
||||
|
||||
if (file.startsWith('tests/') && file.endsWith('.test.cjs')) {
|
||||
@@ -280,6 +354,10 @@ function classify(files) {
|
||||
}
|
||||
}
|
||||
|
||||
// code_changed: true when product/pipeline OR inert CI changed.
|
||||
// Docs-only PRs (neither flag set) get code_changed=false → full matrix skip.
|
||||
const codeChanged = productOrPipelineChanged || inertCiChanged;
|
||||
|
||||
const targetedTests = existingTests([...targeted].sort());
|
||||
|
||||
// When code changed but no rule matched any changed file, fall back to the
|
||||
@@ -290,8 +368,25 @@ function classify(files) {
|
||||
|
||||
const windowsTests = existingTests([...new Set([...windows, ...targetedTests.filter(isWindowsHint)])].sort());
|
||||
|
||||
// Inert-CI-only: full_matrix must be false (override any RULES that fired).
|
||||
if (inertCiChanged && !productOrPipelineChanged) {
|
||||
fullMatrix = false;
|
||||
}
|
||||
|
||||
// Normalize: when code_changed is false, the output must be self-consistent.
|
||||
// A docs file can coincidentally match a coarse content RULE (e.g. docs/installer-migrations.md
|
||||
// matches the installer rule via path.includes('install')), leaving full_matrix=true and
|
||||
// non-empty targeted_tests/windows_tests. The workflow skips correctly (gated on code_changed)
|
||||
// but the output object would be self-contradictory. Force a clean "nothing to run" result.
|
||||
if (!codeChanged) {
|
||||
fullMatrix = false;
|
||||
targetedTests.length = 0;
|
||||
windowsTests.length = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
code_changed: codeChanged,
|
||||
product_changed: productOrPipelineChanged,
|
||||
full_matrix: fullMatrix,
|
||||
targeted_tests: targetedTests,
|
||||
windows_tests: windowsTests,
|
||||
@@ -303,6 +398,7 @@ function writeOutputs(result) {
|
||||
if (!process.env.GITHUB_OUTPUT) return;
|
||||
const lines = [
|
||||
`code_changed=${result.code_changed}`,
|
||||
`product_changed=${result.product_changed}`,
|
||||
`full_matrix=${result.full_matrix}`,
|
||||
`targeted_tests=${result.targeted_tests.join(' ')}`,
|
||||
`windows_tests=${result.windows_tests.join(' ')}`,
|
||||
@@ -313,6 +409,7 @@ function writeOutputs(result) {
|
||||
function main() {
|
||||
try {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const files = changedFiles(args);
|
||||
const result = classify(files);
|
||||
result.changed_files = files;
|
||||
|
||||
Reference in New Issue
Block a user