* test(#431): policy-shell-pinning linter — RED baseline (37 violations on origin/next) Adds scripts/workflow-policy.cjs: H1 shell-policy linter with POLICY map, VIOLATION enum, matrix expansion, effective-shell resolution order, and runPolicyLint({ workflowsDir }) entry point. Adds tests/policy-shell-pinning.test.cjs: 8 tests (baseline + 6 synthetic counter-tests). Synthetic tests 2–7 pass; baseline test is intentionally RED (37 violations: 28 in test.yml, 9 in install-smoke.yml — all macos/windows lanes using shell: bash instead of native zsh/pwsh). Adds js-yaml@4.1.1 as devDependency for YAML parsing. * fix(#431): switch ubuntu/windows lanes to native shells; extract bash-isms to Node Remove all explicit shell: bash pins from ubuntu-only jobs (changes, lint-tests, coverage, required-tests, smoke-unpacked) — ubuntu runner default is bash, which is both H1-compliant and the runner default, making the pin redundant. For the test and test-full mixed-OS jobs (ubuntu+windows, windows+macos): - Move bash-ism steps to shell-agnostic Node scripts: scripts/ci-guard-runner.cjs — RUNNER_ENVIRONMENT check scripts/ci-rebase-check.cjs — git fetch+merge PR base branch scripts/check-npm-integrity.cjs — Node port of check-npm-integrity.sh scripts/ci-prepare-test-scope.cjs — write .ci-selected-tests.txt scripts/ci-smoke-skip.cjs — set skip= output for full-only matrix entries - Remove shell: bash from simple npm/node command steps (runner default applies) This brings Windows violations from 19 to 0. Remaining 17 violations are all MACOS_MISSING_EXPLICIT_ZSH in mixed-OS matrix jobs (test-full: windows+macos, install-smoke smoke: ubuntu+macos) — these require job splitting to fix; see BLOCKER in PR description. * fix(#431): update workflow-shell-pinning test for H1 policy The old test required all Windows-targeting npm steps to pin shell: bash (to prevent pwsh stderr-swallow). Under H1, Windows runners must use pwsh (native, no pin needed) — shell: bash on Windows is now the violation, not the fix. Update findViolations() to flag npm steps with effectiveShell === 'bash' (rather than effectiveShell === null). Update synthetic tests to verify the H1-inverted semantics: defaults.run.shell: bash on Windows is now 2 violations, not 0. Update test name and assertion messages to describe the H1 constraint rather than the old missing-pin constraint. * fix(#431): extend policy linter to resolve matrix.shell expressions - expandRunsOn now captures all matrix.include row keys as realization context (os, node-version, shell, full_only, etc.) instead of only os - effectiveShell now accepts a realizationContext and resolves ${{ matrix.<key> }} expressions against it before checking policy - Unresolvable matrix key in shell expression emits UNRESOLVABLE_MATRIX - Add 3 new tests: positive (zsh+pwsh per row → 0 violations), counter (bash in macOS row → WRONG_SHELL_FOR_OS), counter (missing shell key → UNRESOLVABLE_MATRIX) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#431): apply matrix.shell pattern to test-full and smoke jobs (clears BLOCKER) test-full job (test.yml): - Add shell: pwsh/zsh per matrix.include row (windows-latest→pwsh, macos-latest→zsh) - Add job-level defaults.run.shell: ${{ matrix.shell }} - No step-level shell pins existed to remove smoke job (install-smoke.yml): - Add shell: bash/zsh per matrix.include row (ubuntu→bash, macos→zsh) - Add job-level defaults.run.shell: ${{ matrix.shell }} - No step-level shell pins existed to remove Policy linter now reports 0 violations across all workflow files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(#431): migrate .sh check scripts to .cjs; remove .sh originals - Add scripts/check-env.cjs: Node.js port of check-env.sh with identical exit codes (0/1/2), human-readable and --json output, --help flag, and all 5 checks (node-version, npm-version, lockfile-present, lockfile-sync, version-manager-pin) - Migrate all callers: - package.json check:env → node scripts/check-env.cjs - package.json check:integrity → node scripts/check-npm-integrity.cjs - scripts/ci-test-scope.cjs path strings → .cjs equivalents - .github/workflows/release.yml rc+finalize jobs → node .cjs (drop chmod+x) - .github/workflows/security-scan.yml → node .cjs (drop chmod+x) - tests/check-env.test.cjs → spawn node process.execPath [.cjs] - tests/npm-integrity-gate.test.cjs → spawn node process.execPath [.cjs] - Delete scripts/check-env.sh and scripts/check-npm-integrity.sh Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(#431): update doc references from .sh to .cjs Update SECURITY.md and docs/contributing/bootstrap.md to reference the canonical Node invocation instead of the removed bash scripts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#431): use per-step shell:matrix.shell instead of defaults.run.shell (GHA compat) GHA does not reliably resolve matrix expressions inside defaults.run.shell. Per-step shell: always resolves correctly. Removed the defaults.run.shell block from the test-full job (test.yml) and the smoke job (install-smoke.yml), and added shell: \${{ matrix.shell }} directly on every run: step in both jobs. Codex finding: defaults.run.shell with matrix expressions is not a GHA-supported pattern; per-step shell: is the safe form. * fix(#431): policy linter validates every matrix.include row independently Removed runner-label-only dedup from expandRunsOn() in workflow-policy.cjs. The prior guard (if !realizations.find(r => r.runner === runner)) collapsed two macos-latest rows with different node-version/shell contexts into one, hiding the second row's policy violation. Each matrix.include row is a distinct CI realization with its own context; validating it twice is harmless but skipping it causes false negatives. Added counter-test (Test 8) in tests/policy-shell-pinning.test.cjs: two macos-latest rows (shell:zsh compliant + shell:bash violation) must produce exactly one WRONG_SHELL_FOR_OS violation on the second row. * fix(#431): remove dedup-by-runner in Cartesian matrix.<key> expansion (Codex round 3) The base-list path in expandRunsOn (matrix.<key> arrays, e.g. matrix.os) previously guarded each push with `if (!realizations.find(r => r.runner === runner))`, collapsing duplicate runner values into a single realization and hiding policy violations on later rows of a Cartesian matrix. Remove the guard unconditionally; each entry in the base-list array now produces its own realization, matching the same fix already applied to the matrix.include path. Add counter-test "Cartesian matrix os × shell — dedup must not collapse rows by runner alone": matrix.os: [macos-latest, macos-latest] + shell: ${{ matrix.shell }} now yields 2 realizations (not 1). Documents that Cartesian cross-product expansion (carrying all keys into realization context) is a separate follow-up; current violations are UNRESOLVABLE_MATRIX pending that work. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#431): remove 60s timeout regression on npm ci --dry-run (parity with check-env.sh) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#431): ci-rebase-check.cjs — return truthy sentinel on success (Codex round 4) run() used execFileSync with stdio:'inherit', which returns null on success. Caller checked `result !== null`, always false → every successful fetch fell through to "failed after 3 attempts" exit-1 path. Fix: run() now returns true on success, false on failure. Update caller from `result !== null` to `if (result)`. Adds tests/ci-rebase-check.test.cjs (5 tests) covering the sentinel contract and a local-bare-remote integration smoke that verifies the full fetch+merge path exits 0 when fetch succeeds. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: CI Rebase Check <ci@gsd-redux>
292 lines
8.8 KiB
JavaScript
292 lines
8.8 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
const { execFileSync } = require('child_process');
|
|
const { existsSync, readdirSync, appendFileSync } = require('fs');
|
|
const { join } = require('path');
|
|
|
|
const RULES = [
|
|
{
|
|
name: 'workflow automation',
|
|
match: path => path.startsWith('.github/workflows/') || path.startsWith('.github/rulesets/'),
|
|
fullMatrix: true,
|
|
tests: [
|
|
'tests/workflow-shell-pinning.test.cjs',
|
|
'tests/release-tarball-smoke-workflow.test.cjs',
|
|
'tests/lint-pr-check-project-dir.test.cjs',
|
|
'tests/pr-template-policy.test.cjs',
|
|
],
|
|
},
|
|
{
|
|
name: 'test harness',
|
|
match: path => path === 'scripts/run-tests.cjs',
|
|
fullMatrix: true,
|
|
tests: [
|
|
'tests/run-tests-harness.test.cjs',
|
|
'tests/workflow-shell-pinning.test.cjs',
|
|
],
|
|
},
|
|
{
|
|
name: 'environment and dependency gates',
|
|
match: path => [
|
|
'scripts/check-env.cjs',
|
|
'scripts/check-npm-integrity.cjs',
|
|
'package.json',
|
|
'package-lock.json',
|
|
].includes(path),
|
|
fullMatrix: true,
|
|
tests: [
|
|
'tests/check-env.test.cjs',
|
|
'tests/npm-integrity-gate.test.cjs',
|
|
'tests/package-manifest.test.cjs',
|
|
'tests/bug-3588-npm-audit-clean.test.cjs',
|
|
],
|
|
},
|
|
{
|
|
name: 'installer and package layout',
|
|
match: path => path.startsWith('bin/') ||
|
|
path.startsWith('get-shit-done/bin/') ||
|
|
path.includes('install') ||
|
|
path.includes('release-tarball-smoke'),
|
|
fullMatrix: true,
|
|
tests: [
|
|
'tests/install.test.cjs',
|
|
'tests/install-regressions.test.cjs',
|
|
'tests/install-runtime-artifacts.test.cjs',
|
|
'tests/install-path-detection.test.cjs',
|
|
'tests/release-tarball-smoke.install.test.cjs',
|
|
'tests/runtime-artifact-layout.test.cjs',
|
|
],
|
|
},
|
|
{
|
|
name: 'hooks',
|
|
match: path => path.startsWith('hooks/'),
|
|
fullMatrix: true,
|
|
tests: [
|
|
'tests/hook-validation.test.cjs',
|
|
'tests/managed-hooks.test.cjs',
|
|
'tests/hooks-opt-in.test.cjs',
|
|
'tests/sh-hook-paths.test.cjs',
|
|
'tests/precommit-alias-drift-hook.test.cjs',
|
|
'tests/prepush-enterprise-email-hook.test.cjs',
|
|
],
|
|
},
|
|
{
|
|
name: 'changeset tooling',
|
|
match: path => path.startsWith('scripts/changeset/') || path.startsWith('.changeset/'),
|
|
tests: [
|
|
'tests/changeset-cli.test.cjs',
|
|
'tests/changeset-lint.test.cjs',
|
|
'tests/changeset-new.test.cjs',
|
|
'tests/changeset-parse.test.cjs',
|
|
'tests/changeset-render.test.cjs',
|
|
'tests/changeset-serialize.test.cjs',
|
|
'tests/changeset-github-release-notes.test.cjs',
|
|
],
|
|
},
|
|
{
|
|
name: 'security scanners',
|
|
match: path => path.includes('secret-scan') ||
|
|
path.includes('base64-scan') ||
|
|
path.includes('prompt-injection-scan') ||
|
|
path.startsWith('tests/fixtures/adversarial/security/'),
|
|
tests: [
|
|
'tests/secret-scan-lint.test.cjs',
|
|
'tests/prompt-injection-scan.test.cjs',
|
|
'tests/security-prompt-injection.test.cjs',
|
|
'tests/read-injection-scanner.test.cjs',
|
|
'tests/security-scan.test.cjs',
|
|
],
|
|
},
|
|
{
|
|
name: 'command definitions',
|
|
match: path => path.startsWith('commands/'),
|
|
tests: [
|
|
'tests/command-contract.test.cjs',
|
|
'tests/command-routing-hub.test.cjs',
|
|
'tests/commands.test.cjs',
|
|
'tests/phase-command-router.test.cjs',
|
|
'tests/roadmap-command-router.test.cjs',
|
|
],
|
|
},
|
|
{
|
|
name: 'workflow prompts',
|
|
match: path => path.startsWith('get-shit-done/workflows/'),
|
|
tests: [
|
|
'tests/workflow-compat.test.cjs',
|
|
'tests/workflow-size-budget.test.cjs',
|
|
'tests/workflow-guard-registration.test.cjs',
|
|
'tests/commands.test.cjs',
|
|
'tests/bug-3683-workflow-colon-namespace-leak.test.cjs',
|
|
],
|
|
},
|
|
{
|
|
name: 'agent prompts',
|
|
match: path => path.startsWith('agents/'),
|
|
tests: [
|
|
'tests/agent-frontmatter.test.cjs',
|
|
'tests/agent-size-budget.test.cjs',
|
|
'tests/agent-skills.test.cjs',
|
|
'tests/agent-skills-awareness.test.cjs',
|
|
'tests/agent-required-reading-consistency.test.cjs',
|
|
],
|
|
},
|
|
{
|
|
name: 'configuration',
|
|
match: path => /config|configuration|model-catalog|model-profile/.test(path),
|
|
tests: [
|
|
'tests/config.test.cjs',
|
|
'tests/config-get-default.test.cjs',
|
|
'tests/configuration-migrate-config.test.cjs',
|
|
'tests/model-catalog-runtime-defaults.test.cjs',
|
|
'tests/model-profiles.test.cjs',
|
|
],
|
|
},
|
|
];
|
|
|
|
function usage() {
|
|
return [
|
|
'Usage:',
|
|
' node scripts/ci-test-scope.cjs --base <sha> --head <sha>',
|
|
' node scripts/ci-test-scope.cjs --files <path-list>',
|
|
'',
|
|
'Prints JSON by default. With GITHUB_OUTPUT set, also writes workflow outputs.',
|
|
].join('\n');
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const out = { base: null, head: null, files: null };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg === '--base') {
|
|
out.base = argv[++i];
|
|
if (!out.base || out.base.startsWith('--')) throw new Error('--base requires a value');
|
|
} else if (arg.startsWith('--base=')) {
|
|
out.base = arg.slice('--base='.length);
|
|
if (!out.base) throw new Error('--base requires a value');
|
|
} else if (arg === '--head') {
|
|
out.head = argv[++i];
|
|
if (!out.head || out.head.startsWith('--')) throw new Error('--head requires a value');
|
|
} else if (arg.startsWith('--head=')) {
|
|
out.head = arg.slice('--head='.length);
|
|
if (!out.head) throw new Error('--head requires a value');
|
|
} else if (arg === '--files') {
|
|
out.files = argv[++i];
|
|
if (!out.files || out.files.startsWith('--')) throw new Error('--files requires a value');
|
|
} else if (arg.startsWith('--files=')) {
|
|
out.files = arg.slice('--files='.length);
|
|
if (!out.files) throw new Error('--files requires a value');
|
|
} else if (arg === '--help' || arg === '-h') {
|
|
console.log(usage());
|
|
process.exit(0);
|
|
} else {
|
|
throw new Error(`unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function splitFiles(value) {
|
|
if (!value) return [];
|
|
return value.split(/[,\s]+/).map(v => v.trim()).filter(Boolean);
|
|
}
|
|
|
|
function changedFiles(args) {
|
|
if (args.files) return splitFiles(args.files);
|
|
if (!args.base || !args.head) {
|
|
throw new Error('--base/--head or --files is required');
|
|
}
|
|
const stdout = execFileSync('git', ['diff', '--name-only', args.base, args.head], {
|
|
encoding: 'utf8',
|
|
});
|
|
return splitFiles(stdout);
|
|
}
|
|
|
|
function existingTests(files) {
|
|
const all = new Set(readdirSync('tests').filter(f => f.endsWith('.test.cjs')).map(f => `tests/${f}`));
|
|
return files.filter(file => all.has(file) && existsSync(file));
|
|
}
|
|
|
|
function addAll(set, values) {
|
|
for (const value of values) set.add(value);
|
|
}
|
|
|
|
function classify(files) {
|
|
const targeted = new Set();
|
|
const windows = new Set();
|
|
const reasons = [];
|
|
let codeChanged = false;
|
|
let fullMatrix = false;
|
|
|
|
for (const file of files) {
|
|
if (/^(bin|get-shit-done|agents|commands|hooks|tests|scripts)\//.test(file) ||
|
|
/^package(-lock)?\.json$/.test(file) ||
|
|
/^tsconfig.*\.json$/.test(file) ||
|
|
file.startsWith('.github/workflows/') ||
|
|
file.startsWith('.github/rulesets/')) {
|
|
codeChanged = true;
|
|
}
|
|
|
|
if (file.startsWith('tests/') && file.endsWith('.test.cjs')) {
|
|
targeted.add(file);
|
|
if (/windows|path|shell|workflow|install|hook/i.test(file)) {
|
|
windows.add(file);
|
|
}
|
|
}
|
|
|
|
for (const rule of RULES) {
|
|
if (rule.match(file)) {
|
|
addAll(targeted, rule.tests);
|
|
reasons.push(`${file}: ${rule.name}`);
|
|
if (rule.fullMatrix) fullMatrix = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
const targetedTests = existingTests([...targeted].sort());
|
|
|
|
// When code changed but no rule matched any changed file, fall back to the
|
|
// unit suite so the targeted lane always runs something meaningful (#408).
|
|
if (codeChanged && targetedTests.length === 0) {
|
|
targetedTests.push('unit');
|
|
}
|
|
|
|
const windowsTests = existingTests([...new Set([...windows, ...targetedTests.filter(t => /windows|path|shell|workflow|install|hook/i.test(t))])].sort());
|
|
|
|
return {
|
|
code_changed: codeChanged,
|
|
full_matrix: fullMatrix,
|
|
targeted_tests: targetedTests,
|
|
windows_tests: windowsTests,
|
|
reasons: [...new Set(reasons)].sort(),
|
|
};
|
|
}
|
|
|
|
function writeOutputs(result) {
|
|
if (!process.env.GITHUB_OUTPUT) return;
|
|
const lines = [
|
|
`code_changed=${result.code_changed}`,
|
|
`full_matrix=${result.full_matrix}`,
|
|
`targeted_tests=${result.targeted_tests.join(' ')}`,
|
|
`windows_tests=${result.windows_tests.join(' ')}`,
|
|
];
|
|
appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
|
|
}
|
|
|
|
function main() {
|
|
try {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const files = changedFiles(args);
|
|
const result = classify(files);
|
|
result.changed_files = files;
|
|
writeOutputs(result);
|
|
console.log(JSON.stringify(result, null, 2));
|
|
} catch (error) {
|
|
console.error(`ci-test-scope: ${error.message}`);
|
|
console.error(usage());
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
main();
|