fix(#431): enforce H1 shell policy (linux=bash, macOS=zsh, windows=pwsh) across PR + release gates (#434)
* 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>
This commit is contained in:
297
scripts/check-env.cjs
Normal file
297
scripts/check-env.cjs
Normal file
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
// scripts/check-env.cjs — Environment parity validator for contributors (issue #117).
|
||||
//
|
||||
// Node.js port of scripts/check-env.sh. Behaviorally identical output and
|
||||
// exit codes; shell-agnostic so it runs on Windows, macOS, and Linux.
|
||||
//
|
||||
// Checks that the developer's environment matches project requirements before
|
||||
// running tests or audits. Designed to catch mismatches early rather than
|
||||
// through cryptic test failures.
|
||||
//
|
||||
// Exit codes:
|
||||
// 0 All checks passed
|
||||
// 1 One or more checks failed
|
||||
// 2 Tool error (missing required tool, corrupt package.json, etc.)
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/check-env.cjs # Human-readable report
|
||||
// node scripts/check-env.cjs --json # Structured JSON report
|
||||
// node scripts/check-env.cjs --help # This message
|
||||
//
|
||||
// Sources:
|
||||
// npm engines: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines
|
||||
// Reproducible builds: https://reproducible-builds.org/docs/source-tree/
|
||||
// npm ci docs: https://docs.npmjs.com/cli/v10/commands/npm-ci
|
||||
// gsd-test-runner: https://github.com/open-gsd/gsd-test-runner
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Argument parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
let jsonMode = false;
|
||||
|
||||
for (const arg of process.argv.slice(2)) {
|
||||
if (arg === '--json') {
|
||||
jsonMode = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
process.stdout.write(
|
||||
'scripts/check-env.cjs — Environment parity validator for contributors (issue #117).\n' +
|
||||
'\n' +
|
||||
'Checks that the developer\'s environment matches project requirements before\n' +
|
||||
'running tests or audits. Designed to catch mismatches early rather than\n' +
|
||||
'through cryptic test failures.\n' +
|
||||
'\n' +
|
||||
'Exit codes:\n' +
|
||||
' 0 All checks passed\n' +
|
||||
' 1 One or more checks failed\n' +
|
||||
' 2 Tool error (missing required tool, corrupt package.json, etc.)\n' +
|
||||
'\n' +
|
||||
'Usage:\n' +
|
||||
' node scripts/check-env.cjs # Human-readable report\n' +
|
||||
' node scripts/check-env.cjs --json # Structured JSON report\n' +
|
||||
' node scripts/check-env.cjs --help # This message\n'
|
||||
);
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.stderr.write(`Unknown option: ${arg}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Locate the project root (directory containing package.json)
|
||||
// ---------------------------------------------------------------------------
|
||||
const PROJECT_ROOT = process.cwd();
|
||||
const PACKAGE_JSON = path.join(PROJECT_ROOT, 'package.json');
|
||||
|
||||
if (!fs.existsSync(PACKAGE_JSON)) {
|
||||
process.stderr.write(`ERROR: package.json not found in ${PROJECT_ROOT}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @type {Array<{name: string, status: 'pass'|'fail'|'skip', message: string}>} */
|
||||
const checks = [];
|
||||
|
||||
function addCheck(name, status, message) {
|
||||
checks.push({ name, status, message });
|
||||
}
|
||||
|
||||
/**
|
||||
* Semver comparison: does `version` satisfy `constraint`?
|
||||
* Constraint forms: >=X.Y.Z, >X.Y.Z, <=X.Y.Z, <X.Y.Z, =X.Y.Z, X.Y.Z
|
||||
* Returns true if satisfied, false otherwise.
|
||||
*/
|
||||
function satisfiesConstraint(version, constraint) {
|
||||
// Strip leading 'v' and pre-release/build suffixes
|
||||
version = version.replace(/^v/, '').replace(/-.*$/, '').replace(/\+.*$/, '');
|
||||
|
||||
let op, reqVer;
|
||||
const opMatch = constraint.match(/^(>=|>|<=|<|=)(.+)$/);
|
||||
if (opMatch) {
|
||||
op = opMatch[1];
|
||||
reqVer = opMatch[2];
|
||||
} else {
|
||||
op = '=';
|
||||
reqVer = constraint;
|
||||
}
|
||||
reqVer = reqVer.replace(/^v/, '').replace(/-.*$/, '').replace(/\+.*$/, '');
|
||||
|
||||
function parseTuple(v) {
|
||||
const parts = (v + '.0.0').split('.');
|
||||
return [
|
||||
parseInt(parts[0], 10) || 0,
|
||||
parseInt(parts[1], 10) || 0,
|
||||
parseInt(parts[2], 10) || 0,
|
||||
];
|
||||
}
|
||||
|
||||
const [vMaj, vMin, vPat] = parseTuple(version);
|
||||
const [rMaj, rMin, rPat] = parseTuple(reqVer);
|
||||
|
||||
const vNum = vMaj * 1_000_000 + vMin * 1_000 + vPat;
|
||||
const rNum = rMaj * 1_000_000 + rMin * 1_000 + rPat;
|
||||
|
||||
switch (op) {
|
||||
case '>=': return vNum >= rNum;
|
||||
case '>': return vNum > rNum;
|
||||
case '<=': return vNum <= rNum;
|
||||
case '<': return vNum < rNum;
|
||||
case '=': return vNum === rNum;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a field from package.json using dot-notation (e.g. 'engines.node').
|
||||
* Returns the string value or empty string if absent.
|
||||
* Uses './package.json' so Node resolves relative to CWD on all platforms.
|
||||
*/
|
||||
function pkgField(fieldPath) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
|
||||
let val = pkg;
|
||||
for (const key of fieldPath.split('.')) {
|
||||
if (val == null || typeof val !== 'object') return '';
|
||||
val = val[key];
|
||||
}
|
||||
return val != null ? String(val) : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check 1: Node version vs engines.node
|
||||
// ---------------------------------------------------------------------------
|
||||
const enginesNode = pkgField('engines.node');
|
||||
let currentNode = '';
|
||||
try {
|
||||
currentNode = process.version.replace(/^v/, '');
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (!currentNode) {
|
||||
addCheck('node-version', 'fail', 'node binary not found on PATH');
|
||||
} else if (!enginesNode) {
|
||||
addCheck('node-version', 'fail', 'engines.node missing from package.json — add it (see D2 in docs/contributing/bootstrap.md)');
|
||||
} else {
|
||||
if (satisfiesConstraint(currentNode, enginesNode)) {
|
||||
addCheck('node-version', 'pass', `Node ${currentNode} satisfies ${enginesNode}`);
|
||||
} else {
|
||||
addCheck('node-version', 'fail', `Node ${currentNode} does NOT satisfy engines.node ${enginesNode}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check 2: npm version vs engines.npm (skip if field absent)
|
||||
// ---------------------------------------------------------------------------
|
||||
const enginesNpm = pkgField('engines.npm');
|
||||
let currentNpm = '';
|
||||
try {
|
||||
const res = spawnSync('npm', ['--version'], { encoding: 'utf8', timeout: 10_000 });
|
||||
if (res.status === 0 && res.stdout) {
|
||||
currentNpm = res.stdout.trim();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (!enginesNpm) {
|
||||
addCheck('npm-version', 'skip', 'engines.npm not set in package.json — skipping');
|
||||
} else if (!currentNpm) {
|
||||
addCheck('npm-version', 'fail', 'npm binary not found on PATH');
|
||||
} else {
|
||||
if (satisfiesConstraint(currentNpm, enginesNpm)) {
|
||||
addCheck('npm-version', 'pass', `npm ${currentNpm} satisfies ${enginesNpm}`);
|
||||
} else {
|
||||
addCheck('npm-version', 'fail', `npm ${currentNpm} does NOT satisfy engines.npm ${enginesNpm}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check 3: Lockfile presence
|
||||
// ---------------------------------------------------------------------------
|
||||
const LOCKFILE = path.join(PROJECT_ROOT, 'package-lock.json');
|
||||
if (fs.existsSync(LOCKFILE)) {
|
||||
addCheck('lockfile-present', 'pass', 'package-lock.json exists');
|
||||
} else {
|
||||
addCheck('lockfile-present', 'fail', "package-lock.json missing — run 'npm install' to generate it");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check 4: Lockfile sync (npm ci --dry-run)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (fs.existsSync(LOCKFILE)) {
|
||||
try {
|
||||
const res = spawnSync('npm', ['ci', '--dry-run'], {
|
||||
cwd: PROJECT_ROOT,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (res.status === 0) {
|
||||
addCheck('lockfile-sync', 'pass', 'package-lock.json is in sync with package.json');
|
||||
} else {
|
||||
addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore");
|
||||
}
|
||||
} catch {
|
||||
addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore");
|
||||
}
|
||||
} else {
|
||||
addCheck('lockfile-sync', 'skip', 'skipped — lockfile missing');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check 5: Version manager pin vs active Node
|
||||
// Looks for .nvmrc, .node-version, or .tool-versions at project root.
|
||||
// ---------------------------------------------------------------------------
|
||||
const NVMRC = path.join(PROJECT_ROOT, '.nvmrc');
|
||||
const NODE_VERSION_FILE = path.join(PROJECT_ROOT, '.node-version');
|
||||
const TOOL_VERSIONS = path.join(PROJECT_ROOT, '.tool-versions');
|
||||
|
||||
let pinnedMajor = '';
|
||||
let pinSource = '';
|
||||
|
||||
if (fs.existsSync(NVMRC)) {
|
||||
const content = fs.readFileSync(NVMRC, 'utf8').split('\n')[0].trim().replace(/^v/, '');
|
||||
pinnedMajor = content.split('.')[0];
|
||||
pinSource = '.nvmrc';
|
||||
} else if (fs.existsSync(NODE_VERSION_FILE)) {
|
||||
const content = fs.readFileSync(NODE_VERSION_FILE, 'utf8').split('\n')[0].trim().replace(/^v/, '');
|
||||
pinnedMajor = content.split('.')[0];
|
||||
pinSource = '.node-version';
|
||||
} else if (fs.existsSync(TOOL_VERSIONS)) {
|
||||
const lines = fs.readFileSync(TOOL_VERSIONS, 'utf8').split('\n');
|
||||
const nodeLine = lines.find(l => /^nodejs\s+/.test(l));
|
||||
if (nodeLine) {
|
||||
const ver = nodeLine.split(/\s+/)[1] || '';
|
||||
pinnedMajor = ver.replace(/^v/, '').split('.')[0];
|
||||
pinSource = '.tool-versions';
|
||||
}
|
||||
}
|
||||
|
||||
if (!pinnedMajor) {
|
||||
addCheck('version-manager-pin', 'skip', 'no .nvmrc, .node-version, or .tool-versions found — skipping');
|
||||
} else if (process.env.CI === 'true') {
|
||||
addCheck('version-manager-pin', 'skip', 'CI=true — version-manager pin check skipped (matrix tests multiple Node majors)');
|
||||
} else {
|
||||
const activeMajor = process.version.replace(/^v/, '').split('.')[0];
|
||||
if (activeMajor === pinnedMajor) {
|
||||
addCheck('version-manager-pin', 'pass', `Active Node major (${activeMajor}) matches ${pinSource} pin (${pinnedMajor})`);
|
||||
} else {
|
||||
addCheck('version-manager-pin', 'fail', `Active Node major (${activeMajor}) does NOT match ${pinSource} pin (${pinnedMajor}) — run 'nvm use' or equivalent`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output
|
||||
// ---------------------------------------------------------------------------
|
||||
const overallPass = checks.every(c => c.status !== 'fail');
|
||||
|
||||
if (jsonMode) {
|
||||
// Structured JSON: {pass: bool, checks: [{name, status, message}]}
|
||||
const out = {
|
||||
pass: overallPass,
|
||||
checks: checks.map(c => ({ name: c.name, status: c.status, message: c.message })),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
||||
} else {
|
||||
// Human-readable report
|
||||
process.stdout.write('=== Environment Check ===\n');
|
||||
for (const { name, status, message } of checks) {
|
||||
const icon = status === 'pass' ? '[PASS]' : status === 'fail' ? '[FAIL]' : '[SKIP]';
|
||||
const namePadded = name.padEnd(25);
|
||||
process.stdout.write(` ${icon} ${namePadded} ${message}\n`);
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
if (overallPass) {
|
||||
process.stdout.write('Result: ALL CHECKS PASSED\n');
|
||||
} else {
|
||||
process.stdout.write('Result: ONE OR MORE CHECKS FAILED — see above\n');
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(overallPass ? 0 : 1);
|
||||
@@ -1,332 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/check-env.sh — Environment parity validator for contributors (issue #117).
|
||||
#
|
||||
# Checks that the developer's environment matches project requirements before
|
||||
# running tests or audits. Designed to catch mismatches early rather than
|
||||
# through cryptic test failures.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 All checks passed
|
||||
# 1 One or more checks failed
|
||||
# 2 Tool error (missing required tool, corrupt package.json, etc.)
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/check-env.sh # Human-readable report
|
||||
# ./scripts/check-env.sh --json # Structured JSON report
|
||||
# ./scripts/check-env.sh --help # This message
|
||||
#
|
||||
# Sources:
|
||||
# npm engines: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines
|
||||
# Reproducible builds: https://reproducible-builds.org/docs/source-tree/
|
||||
# npm ci docs: https://docs.npmjs.com/cli/v10/commands/npm-ci
|
||||
# gsd-test-runner: https://github.com/open-gsd/gsd-test-runner
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
JSON_MODE=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--json) JSON_MODE=true ;;
|
||||
--help|-h)
|
||||
# Print header comment block (lines starting with #, stopping at first non-comment)
|
||||
while IFS= read -r line; do
|
||||
if [[ "${line}" =~ ^# ]]; then
|
||||
printf '%s\n' "${line#\# }"
|
||||
elif [[ -z "${line}" ]]; then
|
||||
continue
|
||||
else
|
||||
break
|
||||
fi
|
||||
done < "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Locate the project root (directory containing package.json)
|
||||
# We resolve relative to CWD, not the script location, so callers can pass
|
||||
# a --cwd by simply cd-ing before invoking.
|
||||
# ---------------------------------------------------------------------------
|
||||
PROJECT_ROOT="${PWD}"
|
||||
PACKAGE_JSON="${PROJECT_ROOT}/package.json"
|
||||
|
||||
if [[ ! -f "${PACKAGE_JSON}" ]]; then
|
||||
echo "ERROR: package.json not found in ${PROJECT_ROOT}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Emit a check result line.
|
||||
# Args: name, status (pass|fail|skip), message
|
||||
CHECKS=() # Each entry: "name|status|message"
|
||||
|
||||
add_check() {
|
||||
local name="$1"
|
||||
local status="$2"
|
||||
local message="$3"
|
||||
CHECKS+=("${name}|${status}|${message}")
|
||||
}
|
||||
|
||||
# Semver comparison: does version $1 satisfy constraint $2?
|
||||
# Constraint forms supported: >=X.Y.Z, >X.Y.Z, <=X.Y.Z, <X.Y.Z, =X.Y.Z, X.Y.Z
|
||||
# Also supports npm range: >=X (no minor/patch required).
|
||||
# Returns 0 if satisfied, 1 if not.
|
||||
satisfies_constraint() {
|
||||
local version="$1"
|
||||
local constraint="$2"
|
||||
|
||||
# Strip leading 'v' from version
|
||||
version="${version#v}"
|
||||
# Strip pre-release suffixes (e.g. 26.0.0-rc.1 → 26.0.0)
|
||||
version="${version%%-*}"
|
||||
version="${version%%+*}"
|
||||
|
||||
# Parse operator and required version from constraint.
|
||||
# Support: >=, >, <=, <, =, (bare) version
|
||||
local op req_ver
|
||||
if [[ "$constraint" =~ ^(>=|>|<=|<|=)(.+)$ ]]; then
|
||||
op="${BASH_REMATCH[1]}"
|
||||
req_ver="${BASH_REMATCH[2]}"
|
||||
else
|
||||
op="="
|
||||
req_ver="$constraint"
|
||||
fi
|
||||
req_ver="${req_ver#v}"
|
||||
req_ver="${req_ver%%-*}"
|
||||
req_ver="${req_ver%%+*}"
|
||||
|
||||
# Extract major, minor, patch using field splitting on '.'
|
||||
# We pad the version strings to ensure exactly 3 dot-separated fields.
|
||||
# Padding trick: append ".0.0" then take first 3 fields via cut.
|
||||
local v_padded="${version}.0.0"
|
||||
local v_major v_minor v_patch
|
||||
v_major="$(printf '%s' "${v_padded}" | cut -d. -f1)"
|
||||
v_minor="$(printf '%s' "${v_padded}" | cut -d. -f2)"
|
||||
v_patch="$(printf '%s' "${v_padded}" | cut -d. -f3)"
|
||||
v_major="${v_major:-0}"; v_minor="${v_minor:-0}"; v_patch="${v_patch:-0}"
|
||||
|
||||
local r_padded="${req_ver}.0.0"
|
||||
local r_major r_minor r_patch
|
||||
r_major="$(printf '%s' "${r_padded}" | cut -d. -f1)"
|
||||
r_minor="$(printf '%s' "${r_padded}" | cut -d. -f2)"
|
||||
r_patch="$(printf '%s' "${r_padded}" | cut -d. -f3)"
|
||||
r_major="${r_major:-0}"; r_minor="${r_minor:-0}"; r_patch="${r_patch:-0}"
|
||||
|
||||
# Numeric tuple comparison using arithmetic.
|
||||
local v_num=$(( v_major * 1000000 + v_minor * 1000 + v_patch ))
|
||||
local r_num=$(( r_major * 1000000 + r_minor * 1000 + r_patch ))
|
||||
|
||||
case "$op" in
|
||||
">=") [[ $v_num -ge $r_num ]] ;;
|
||||
">") [[ $v_num -gt $r_num ]] ;;
|
||||
"<=") [[ $v_num -le $r_num ]] ;;
|
||||
"<") [[ $v_num -lt $r_num ]] ;;
|
||||
"=") [[ $v_num -eq $r_num ]] ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Read a field from package.json using node (avoids requiring jq).
|
||||
# Uses a relative path './package.json' so that Node receives a path it can
|
||||
# resolve on every platform — including Windows where Git Bash exposes $PWD
|
||||
# as a POSIX path (/d/a/…) that node.exe cannot open via fs.readFileSync.
|
||||
# pkg_field is always called before any `cd` in this script, so CWD is
|
||||
# PROJECT_ROOT and './package.json' always resolves correctly.
|
||||
pkg_field() {
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
let pkg;
|
||||
try { pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8')); } catch(e) { process.exit(0); }
|
||||
const val = '${1}'.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : null), pkg);
|
||||
if (val !== null) process.stdout.write(String(val));
|
||||
" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check 1: Node version vs engines.node
|
||||
# ---------------------------------------------------------------------------
|
||||
ENGINES_NODE="$(pkg_field engines.node)"
|
||||
CURRENT_NODE="$(node --version 2>/dev/null || echo '')"
|
||||
CURRENT_NODE="${CURRENT_NODE#v}"
|
||||
|
||||
if [[ -z "${CURRENT_NODE}" ]]; then
|
||||
add_check "node-version" "fail" "node binary not found on PATH"
|
||||
elif [[ -z "${ENGINES_NODE}" ]]; then
|
||||
add_check "node-version" "fail" "engines.node missing from package.json — add it (see D2 in docs/contributing/bootstrap.md)"
|
||||
else
|
||||
if satisfies_constraint "${CURRENT_NODE}" "${ENGINES_NODE}"; then
|
||||
add_check "node-version" "pass" "Node ${CURRENT_NODE} satisfies ${ENGINES_NODE}"
|
||||
else
|
||||
add_check "node-version" "fail" "Node ${CURRENT_NODE} does NOT satisfy engines.node ${ENGINES_NODE}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check 2: npm version vs engines.npm (skip if field absent)
|
||||
# ---------------------------------------------------------------------------
|
||||
ENGINES_NPM="$(pkg_field engines.npm)"
|
||||
CURRENT_NPM="$(npm --version 2>/dev/null || echo '')"
|
||||
|
||||
if [[ -z "${ENGINES_NPM}" ]]; then
|
||||
add_check "npm-version" "skip" "engines.npm not set in package.json — skipping"
|
||||
elif [[ -z "${CURRENT_NPM}" ]]; then
|
||||
add_check "npm-version" "fail" "npm binary not found on PATH"
|
||||
else
|
||||
if satisfies_constraint "${CURRENT_NPM}" "${ENGINES_NPM}"; then
|
||||
add_check "npm-version" "pass" "npm ${CURRENT_NPM} satisfies ${ENGINES_NPM}"
|
||||
else
|
||||
add_check "npm-version" "fail" "npm ${CURRENT_NPM} does NOT satisfy engines.npm ${ENGINES_NPM}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check 3: Lockfile presence
|
||||
# ---------------------------------------------------------------------------
|
||||
LOCKFILE="${PROJECT_ROOT}/package-lock.json"
|
||||
if [[ -f "${LOCKFILE}" ]]; then
|
||||
add_check "lockfile-present" "pass" "package-lock.json exists"
|
||||
else
|
||||
add_check "lockfile-present" "fail" "package-lock.json missing — run 'npm install' to generate it"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check 4: Lockfile sync (npm ci --dry-run)
|
||||
# Skip if lockfile is missing (already failed above).
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ -f "${LOCKFILE}" ]]; then
|
||||
# npm ci --dry-run exits 0 when in sync; exits non-zero when it would mutate.
|
||||
if (cd "${PROJECT_ROOT}" && npm ci --dry-run >/dev/null 2>&1); then
|
||||
add_check "lockfile-sync" "pass" "package-lock.json is in sync with package.json"
|
||||
else
|
||||
add_check "lockfile-sync" "fail" "package-lock.json is out of sync — run 'npm ci' to restore"
|
||||
fi
|
||||
else
|
||||
add_check "lockfile-sync" "skip" "skipped — lockfile missing"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check 5: Version manager pin vs active Node
|
||||
# Looks for .nvmrc, .node-version, or .tool-versions at project root.
|
||||
# ---------------------------------------------------------------------------
|
||||
NVMRC="${PROJECT_ROOT}/.nvmrc"
|
||||
NODE_VERSION_FILE="${PROJECT_ROOT}/.node-version"
|
||||
TOOL_VERSIONS="${PROJECT_ROOT}/.tool-versions"
|
||||
|
||||
PINNED_MAJOR=""
|
||||
PIN_SOURCE=""
|
||||
|
||||
if [[ -f "${NVMRC}" ]]; then
|
||||
NVMRC_CONTENT="$(head -1 "${NVMRC}" | tr -d '[:space:]')"
|
||||
# Strip leading 'v' and extract major
|
||||
NVMRC_CONTENT="${NVMRC_CONTENT#v}"
|
||||
PINNED_MAJOR="${NVMRC_CONTENT%%.*}"
|
||||
PIN_SOURCE=".nvmrc"
|
||||
elif [[ -f "${NODE_VERSION_FILE}" ]]; then
|
||||
NV_CONTENT="$(head -1 "${NODE_VERSION_FILE}" | tr -d '[:space:]')"
|
||||
NV_CONTENT="${NV_CONTENT#v}"
|
||||
PINNED_MAJOR="${NV_CONTENT%%.*}"
|
||||
PIN_SOURCE=".node-version"
|
||||
elif [[ -f "${TOOL_VERSIONS}" ]]; then
|
||||
# asdf/mise format: "nodejs 22.x.x"
|
||||
TV_LINE="$(grep -E '^nodejs ' "${TOOL_VERSIONS}" || true)"
|
||||
if [[ -n "${TV_LINE}" ]]; then
|
||||
TV_VER="$(echo "${TV_LINE}" | awk '{print $2}')"
|
||||
TV_VER="${TV_VER#v}"
|
||||
PINNED_MAJOR="${TV_VER%%.*}"
|
||||
PIN_SOURCE=".tool-versions"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${PINNED_MAJOR}" ]]; then
|
||||
add_check "version-manager-pin" "skip" "no .nvmrc, .node-version, or .tool-versions found — skipping"
|
||||
elif [[ "${CI:-}" == "true" ]]; then
|
||||
# In CI the matrix explicitly tests multiple Node majors, so a pin-mismatch
|
||||
# is expected and intentional. Skip rather than fail to avoid blocking the
|
||||
# non-22 matrix rows (Node 24, 26, …) while still exercising all other checks.
|
||||
add_check "version-manager-pin" "skip" "CI=true — version-manager pin check skipped (matrix tests multiple Node majors)"
|
||||
else
|
||||
ACTIVE_MAJOR="${CURRENT_NODE%%.*}"
|
||||
if [[ "${ACTIVE_MAJOR}" == "${PINNED_MAJOR}" ]]; then
|
||||
add_check "version-manager-pin" "pass" "Active Node major (${ACTIVE_MAJOR}) matches ${PIN_SOURCE} pin (${PINNED_MAJOR})"
|
||||
else
|
||||
add_check "version-manager-pin" "fail" "Active Node major (${ACTIVE_MAJOR}) does NOT match ${PIN_SOURCE} pin (${PINNED_MAJOR}) — run 'nvm use' or equivalent"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
OVERALL_PASS=true
|
||||
for check in "${CHECKS[@]}"; do
|
||||
IFS='|' read -r name status message <<< "$check"
|
||||
if [[ "$status" == "fail" ]]; then
|
||||
OVERALL_PASS=false
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${JSON_MODE}" == "true" ]]; then
|
||||
# Emit structured JSON: {pass: bool, checks: [{name, status, message}]}
|
||||
PASS_VAL="false"
|
||||
[[ "${OVERALL_PASS}" == "true" ]] && PASS_VAL="true"
|
||||
|
||||
printf '{\n'
|
||||
printf ' "pass": %s,\n' "${PASS_VAL}"
|
||||
printf ' "checks": [\n'
|
||||
|
||||
total="${#CHECKS[@]}"
|
||||
idx=0
|
||||
for check in "${CHECKS[@]}"; do
|
||||
idx=$(( idx + 1 ))
|
||||
IFS='|' read -r name status message <<< "$check"
|
||||
# Escape double-quotes in message for JSON
|
||||
message="${message//\"/\\\"}"
|
||||
if [[ $idx -lt $total ]]; then
|
||||
printf ' {"name": "%s", "status": "%s", "message": "%s"},\n' \
|
||||
"${name}" "${status}" "${message}"
|
||||
else
|
||||
printf ' {"name": "%s", "status": "%s", "message": "%s"}\n' \
|
||||
"${name}" "${status}" "${message}"
|
||||
fi
|
||||
done
|
||||
|
||||
printf ' ]\n'
|
||||
printf '}\n'
|
||||
else
|
||||
# Human-readable report
|
||||
echo "=== Environment Check ==="
|
||||
for check in "${CHECKS[@]}"; do
|
||||
IFS='|' read -r name status message <<< "$check"
|
||||
case "$status" in
|
||||
pass) icon="[PASS]" ;;
|
||||
fail) icon="[FAIL]" ;;
|
||||
skip) icon="[SKIP]" ;;
|
||||
*) icon="[????]" ;;
|
||||
esac
|
||||
printf " %s %-25s %s\n" "$icon" "$name" "$message"
|
||||
done
|
||||
echo ""
|
||||
if [[ "${OVERALL_PASS}" == "true" ]]; then
|
||||
echo "Result: ALL CHECKS PASSED"
|
||||
else
|
||||
echo "Result: ONE OR MORE CHECKS FAILED — see above"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Exit code
|
||||
if [[ "${OVERALL_PASS}" == "true" ]]; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
209
scripts/check-npm-integrity.cjs
Normal file
209
scripts/check-npm-integrity.cjs
Normal file
@@ -0,0 +1,209 @@
|
||||
'use strict';
|
||||
// check-npm-integrity.cjs — Node.js port of scripts/check-npm-integrity.sh
|
||||
// Shell-agnostic replacement for the "Dependency integrity gate" CI step.
|
||||
// Invoked as: node scripts/check-npm-integrity.cjs [--ignore-extraneous]
|
||||
//
|
||||
// Parses package-lock.json in cwd and exits non-zero if any package is:
|
||||
// INVALID — resolved version does not satisfy declared semver range
|
||||
// MISSING — declared in package.json but absent from lockfile packages map
|
||||
// EXTRANEOUS — marked extraneous: true in lockfile (unless --ignore-extraneous)
|
||||
//
|
||||
// Exit codes:
|
||||
// 0 = clean
|
||||
// 1 = integrity drift detected
|
||||
// 2 = tool error (lockfile missing, JSON parse failure, unknown arg)
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ---- Argument parsing -------------------------------------------------------
|
||||
|
||||
let ignoreExtraneous = false;
|
||||
|
||||
for (const arg of process.argv.slice(2)) {
|
||||
if (arg === '--ignore-extraneous') {
|
||||
ignoreExtraneous = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
process.stdout.write(
|
||||
'Usage: node scripts/check-npm-integrity.cjs [--ignore-extraneous]\n'
|
||||
);
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.stderr.write(`ERROR: Unknown argument: ${arg}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Locate lockfile --------------------------------------------------------
|
||||
|
||||
const lockfilePath = path.join(process.cwd(), 'package-lock.json');
|
||||
|
||||
if (!fs.existsSync(lockfilePath)) {
|
||||
process.stderr.write(`ERROR: package-lock.json not found in ${process.cwd()}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// ---- Parse lockfile ---------------------------------------------------------
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(fs.readFileSync(lockfilePath, 'utf-8'));
|
||||
} catch (e) {
|
||||
process.stderr.write(`ERROR: Failed to parse package-lock.json: ${e.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const lockVersion = lock.lockfileVersion || 1;
|
||||
if (lockVersion < 2) {
|
||||
process.stderr.write(
|
||||
`ERROR: package-lock.json lockfileVersion ${lockVersion} is not supported. ` +
|
||||
'Run `npm install` to upgrade to v3.\n'
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const packages = lock.packages || {};
|
||||
const rootEntry = packages[''] || {};
|
||||
|
||||
// Collect declared dependency ranges from root entry.
|
||||
const declaredRanges = {};
|
||||
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
|
||||
for (const [name, range] of Object.entries(rootEntry[field] || {})) {
|
||||
if (!declaredRanges[name]) declaredRanges[name] = range;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Minimal semver satisfies -----------------------------------------------
|
||||
|
||||
function parseVersion(v) {
|
||||
const m = String(v).match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||
if (!m) return null;
|
||||
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
|
||||
}
|
||||
|
||||
function cmpVersion(a, b) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function satisfies(installed, range) {
|
||||
range = String(range).trim();
|
||||
if (!range || range === '*' || range === 'latest') return true;
|
||||
if (/^\d/.test(range)) {
|
||||
const iv = parseVersion(installed);
|
||||
const rv = parseVersion(range);
|
||||
if (!iv || !rv) return installed === range;
|
||||
return cmpVersion(iv, rv) === 0;
|
||||
}
|
||||
if (range[0] === '^') {
|
||||
const base = parseVersion(range.slice(1));
|
||||
const inst = parseVersion(installed);
|
||||
if (!base || !inst) return false;
|
||||
if (cmpVersion(inst, base) < 0) return false;
|
||||
if (base[0] > 0) return inst[0] === base[0];
|
||||
if (base[1] > 0) return inst[0] === 0 && inst[1] === base[1];
|
||||
return inst[0] === 0 && inst[1] === 0 && inst[2] === base[2];
|
||||
}
|
||||
if (range[0] === '~') {
|
||||
const tbase = parseVersion(range.slice(1));
|
||||
const tinst = parseVersion(installed);
|
||||
if (!tbase || !tinst) return false;
|
||||
if (cmpVersion(tinst, tbase) < 0) return false;
|
||||
return tinst[0] === tbase[0] && tinst[1] === tbase[1];
|
||||
}
|
||||
const opMatch = range.match(/^(>=|<=|>|<|=)\s*(.+)/);
|
||||
if (opMatch) {
|
||||
const op = opMatch[1];
|
||||
const ov = parseVersion(opMatch[2]);
|
||||
const iv2 = parseVersion(installed);
|
||||
if (!ov || !iv2) return false;
|
||||
const c = cmpVersion(iv2, ov);
|
||||
if (op === '>=') return c >= 0;
|
||||
if (op === '<=') return c <= 0;
|
||||
if (op === '>') return c > 0;
|
||||
if (op === '<') return c < 0;
|
||||
if (op === '=') return c === 0;
|
||||
}
|
||||
if (range.includes(' ')) {
|
||||
return range.split(/\s+/).every(part => satisfies(installed, part));
|
||||
}
|
||||
return installed === range;
|
||||
}
|
||||
|
||||
// ---- Walk packages map ------------------------------------------------------
|
||||
|
||||
const invalids = [];
|
||||
const missings = [];
|
||||
const extraneousFound = [];
|
||||
|
||||
for (const [key, entry] of Object.entries(packages)) {
|
||||
if (!key.startsWith('node_modules/')) continue;
|
||||
const rest = key.slice('node_modules/'.length);
|
||||
const isScoped = rest[0] === '@';
|
||||
const slashCount = (rest.match(/\//g) || []).length;
|
||||
if (isScoped && slashCount > 1) continue;
|
||||
if (!isScoped && slashCount > 0) continue;
|
||||
|
||||
const pkgName = rest;
|
||||
const installedVersion = entry.version || '';
|
||||
|
||||
if (entry.extraneous) {
|
||||
extraneousFound.push({ name: pkgName, version: installedVersion });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!declaredRanges[pkgName]) continue;
|
||||
|
||||
if (!satisfies(installedVersion, declaredRanges[pkgName])) {
|
||||
invalids.push({ name: pkgName, version: installedVersion, declared: declaredRanges[pkgName] });
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of Object.keys(declaredRanges)) {
|
||||
if (!packages[`node_modules/${name}`]) {
|
||||
missings.push({ name, required: declaredRanges[name] });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Verdict ----------------------------------------------------------------
|
||||
|
||||
const failInvalid = invalids.length > 0;
|
||||
const failMissing = missings.length > 0;
|
||||
const failExtra = !ignoreExtraneous && extraneousFound.length > 0;
|
||||
|
||||
if (!failInvalid && !failMissing && !failExtra) {
|
||||
process.stderr.write('check-npm-integrity.cjs: clean\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const lines = ['FAIL: dependency integrity drift detected', ''];
|
||||
|
||||
if (failInvalid) {
|
||||
lines.push(' INVALID (installed version does not satisfy declared range):');
|
||||
for (const { name, declared, version } of invalids) {
|
||||
lines.push(` ${name}: declared=${declared} installed=${version}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (failMissing) {
|
||||
lines.push(' MISSING (declared but absent from lockfile packages map):');
|
||||
for (const { name, required } of missings) {
|
||||
lines.push(` ${name}@${required}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (failExtra) {
|
||||
lines.push(' EXTRANEOUS (in lockfile but not declared as a dependency):');
|
||||
for (const { name, version } of extraneousFound) {
|
||||
lines.push(` ${name}@${version}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('Remediation: rm -rf node_modules && npm ci');
|
||||
process.stderr.write(lines.join('\n') + '\n');
|
||||
process.exit(1);
|
||||
@@ -1,369 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-npm-integrity.sh -- Enforce npm dependency integrity baseline
|
||||
#
|
||||
# Detects invalid, missing, and extraneous packages by parsing the
|
||||
# package-lock.json in the current directory (lockfileVersion 2 or 3).
|
||||
# Exits non-zero when any integrity problem is found, emitting a structured
|
||||
# report to stderr listing every offender.
|
||||
#
|
||||
# Source: https://docs.npmjs.com/cli/v10/commands/npm-ls
|
||||
# lockfileVersion 3 "packages" map records every resolved package under
|
||||
# "node_modules/<name>" with its resolved version. The root entry "" holds
|
||||
# the declared dependency ranges. This script compares the two without
|
||||
# requiring node_modules to be present on disk, making it safe to run in
|
||||
# CI environments before `npm ci`.
|
||||
#
|
||||
# Detection rules (all derived from package-lock.json):
|
||||
# - MISSING : declared in root dependencies but absent from packages map
|
||||
# - INVALID : in packages map but installed version does not satisfy the
|
||||
# declared semver range
|
||||
# - EXTRANEOUS : appears in packages map with "extraneous": true, OR present
|
||||
# in packages map but not declared in any root dependency field
|
||||
#
|
||||
# Workspace behaviour: if the root package.json declares a "workspaces" field,
|
||||
# npm ls traverses all workspace packages automatically (npm >=7). This script
|
||||
# runs at the directory where it is invoked; for workspace repos, invoke from
|
||||
# the root. The sdk/ sub-package in this repo is NOT a declared workspace and
|
||||
# is therefore out of scope for this single invocation.
|
||||
#
|
||||
# Security context:
|
||||
# NIST SSDF PW.4.1 -- use components from well-governed, secure sources:
|
||||
# https://csrc.nist.gov/publications/detail/sp/800-218/final
|
||||
# OpenSSF Scorecard "Pinned-Dependencies" rationale:
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/check-npm-integrity.sh [--ignore-extraneous] [--help]
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 = clean (no integrity problems, or only extraneous and --ignore-extraneous set)
|
||||
# 1 = integrity drift detected (invalid, missing, or extraneous packages)
|
||||
# 2 = tool error (node not found, JSON parse failure, missing lockfile, or usage error)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
|
||||
IGNORE_EXTRANEOUS=false
|
||||
|
||||
# ---- Usage ------------------------------------------------------------------
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'USAGE'
|
||||
Usage: check-npm-integrity.sh [OPTIONS]
|
||||
|
||||
Verify that the npm lockfile in the current directory is internally consistent.
|
||||
|
||||
Parses package-lock.json and fails if any package is:
|
||||
- invalid (resolved version does not satisfy the declared semver range)
|
||||
- missing (declared in package.json / lockfile root but absent from packages map)
|
||||
- extraneous (present in packages map but not declared as a dependency,
|
||||
or marked extraneous: true in the lockfile)
|
||||
|
||||
Options:
|
||||
--ignore-extraneous Allow extraneous packages; only fail on invalid/missing
|
||||
--help Print this help message and exit 0
|
||||
|
||||
Exit codes:
|
||||
0 Clean -- no integrity problems
|
||||
1 Drift detected -- see stderr for details
|
||||
2 Tool error -- node not found, JSON parse failure, missing lockfile, or usage error
|
||||
|
||||
Remediation:
|
||||
rm -rf node_modules && npm ci
|
||||
|
||||
Sources:
|
||||
npm lockfile docs: https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json
|
||||
NIST SSDF PW.4.1: https://csrc.nist.gov/publications/detail/sp/800-218/final
|
||||
USAGE
|
||||
}
|
||||
|
||||
# ---- Argument parsing -------------------------------------------------------
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--ignore-extraneous)
|
||||
IGNORE_EXTRANEOUS=true
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown argument: $arg" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- Prerequisite check -----------------------------------------------------
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "ERROR: node not found on PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ---- Locate lockfile --------------------------------------------------------
|
||||
|
||||
LOCKFILE="$(pwd)/package-lock.json"
|
||||
|
||||
if [ ! -f "$LOCKFILE" ]; then
|
||||
echo "ERROR: package-lock.json not found in $(pwd)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ---- Parse lockfile via Node.js ---------------------------------------------
|
||||
#
|
||||
# Write the parser to a temp file to avoid heredoc/stdin conflicts.
|
||||
# The lockfile path and ignore-extraneous flag are passed as argv.
|
||||
|
||||
GATE_TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$GATE_TMP"' EXIT
|
||||
|
||||
cat > "$GATE_TMP/parser.js" << 'ENDPARSER'
|
||||
'use strict';
|
||||
// argv[2] = absolute path to package-lock.json
|
||||
// argv[3] = "true"|"false" for --ignore-extraneous
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
|
||||
var lockfilePath = process.argv[2];
|
||||
var ignoreExtraneous = process.argv[3] === 'true';
|
||||
|
||||
var raw;
|
||||
try {
|
||||
raw = fs.readFileSync(lockfilePath, 'utf-8');
|
||||
} catch (e) {
|
||||
process.stderr.write('ERROR: Cannot read ' + lockfilePath + ': ' + e.message + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
var lock;
|
||||
try {
|
||||
lock = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
process.stderr.write('ERROR: Failed to parse package-lock.json: ' + e.message + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Only lockfileVersion 2+ uses the "packages" map we rely on.
|
||||
var lockVersion = lock.lockfileVersion || 1;
|
||||
if (lockVersion < 2) {
|
||||
process.stderr.write(
|
||||
'ERROR: package-lock.json lockfileVersion ' + lockVersion +
|
||||
' is not supported. Run `npm install` to upgrade to v3.\n'
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
var packages = lock.packages || {};
|
||||
var rootEntry = packages[''] || {};
|
||||
|
||||
// Collect all declared dependency ranges from root entry.
|
||||
// Include dependencies, devDependencies, optionalDependencies, peerDependencies.
|
||||
var declaredRanges = {};
|
||||
var depFields = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
|
||||
for (var fi = 0; fi < depFields.length; fi++) {
|
||||
var field = depFields[fi];
|
||||
var deps = rootEntry[field] || {};
|
||||
var depNames = Object.keys(deps);
|
||||
for (var di = 0; di < depNames.length; di++) {
|
||||
var dname = depNames[di];
|
||||
if (!declaredRanges[dname]) {
|
||||
declaredRanges[dname] = deps[dname];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Minimal semver satisfies implementation --------------------------------
|
||||
// Supports: exact version ("1.2.3"), caret ("^1.2.3"), tilde ("~1.2.3"),
|
||||
// comparison operators (">=1.0.0 <2.0.0"), and wildcards ("*", "").
|
||||
// For the integrity gate use-case (comparing lockfile resolved vs declared),
|
||||
// full semver is rarely needed — exact-version and simple ranges cover ~99%.
|
||||
|
||||
function parseVersion(v) {
|
||||
var m = String(v).match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||
if (!m) return null;
|
||||
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
|
||||
}
|
||||
|
||||
function cmpVersion(a, b) {
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function satisfies(installed, range) {
|
||||
range = String(range).trim();
|
||||
// Wildcard / empty
|
||||
if (!range || range === '*' || range === 'latest') return true;
|
||||
// Exact version (no operator)
|
||||
if (/^\d/.test(range)) {
|
||||
var iv = parseVersion(installed);
|
||||
var rv = parseVersion(range);
|
||||
if (!iv || !rv) return installed === range;
|
||||
return cmpVersion(iv, rv) === 0;
|
||||
}
|
||||
// Caret range: ^X.Y.Z → >=X.Y.Z <(X+1).0.0 (major must match for X>0)
|
||||
if (range.charAt(0) === '^') {
|
||||
var base = parseVersion(range.slice(1));
|
||||
var inst = parseVersion(installed);
|
||||
if (!base || !inst) return false;
|
||||
if (cmpVersion(inst, base) < 0) return false;
|
||||
if (base[0] > 0) return inst[0] === base[0];
|
||||
if (base[1] > 0) return inst[0] === 0 && inst[1] === base[1];
|
||||
return inst[0] === 0 && inst[1] === 0 && inst[2] === base[2];
|
||||
}
|
||||
// Tilde range: ~X.Y.Z → >=X.Y.Z <X.(Y+1).0
|
||||
if (range.charAt(0) === '~') {
|
||||
var tbase = parseVersion(range.slice(1));
|
||||
var tinst = parseVersion(installed);
|
||||
if (!tbase || !tinst) return false;
|
||||
if (cmpVersion(tinst, tbase) < 0) return false;
|
||||
return tinst[0] === tbase[0] && tinst[1] === tbase[1];
|
||||
}
|
||||
// Simple comparison operators: >=, <=, >, <, =
|
||||
var opMatch = range.match(/^(>=|<=|>|<|=)\s*(.+)/);
|
||||
if (opMatch) {
|
||||
var op = opMatch[1];
|
||||
var ov = parseVersion(opMatch[2]);
|
||||
var iv2 = parseVersion(installed);
|
||||
if (!ov || !iv2) return false;
|
||||
var c = cmpVersion(iv2, ov);
|
||||
if (op === '>=') return c >= 0;
|
||||
if (op === '<=') return c <= 0;
|
||||
if (op === '>') return c > 0;
|
||||
if (op === '<') return c < 0;
|
||||
if (op === '=') return c === 0;
|
||||
}
|
||||
// Compound range (space-separated, e.g. ">=1.0.0 <2.0.0")
|
||||
if (range.indexOf(' ') !== -1) {
|
||||
var parts = range.split(/\s+/);
|
||||
for (var pi = 0; pi < parts.length; pi++) {
|
||||
if (!satisfies(installed, parts[pi])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Fallback: string equality
|
||||
return installed === range;
|
||||
}
|
||||
|
||||
// ---- Walk packages map ------------------------------------------------------
|
||||
|
||||
var invalids = [];
|
||||
var missings = [];
|
||||
var extraneousFound = [];
|
||||
|
||||
// Check each node_modules/<name> entry in the lockfile.
|
||||
var pkgKeys = Object.keys(packages);
|
||||
for (var ki = 0; ki < pkgKeys.length; ki++) {
|
||||
var key = pkgKeys[ki];
|
||||
if (!key.startsWith('node_modules/')) continue;
|
||||
// Skip workspace sub-packages (contain a second slash after node_modules/)
|
||||
var rest = key.slice('node_modules/'.length);
|
||||
// Scoped packages (@scope/name) have one slash; skip nested like node_modules/a/node_modules/b
|
||||
var slashCount = (rest.match(/\//g) || []).length;
|
||||
var isScoped = rest.charAt(0) === '@';
|
||||
if (isScoped && slashCount > 1) continue;
|
||||
if (!isScoped && slashCount > 0) continue;
|
||||
|
||||
var pkgName = rest;
|
||||
var entry = packages[key];
|
||||
var installedVersion = entry.version || '';
|
||||
|
||||
// Explicitly marked extraneous by npm in the lockfile.
|
||||
if (entry.extraneous) {
|
||||
extraneousFound.push({ name: pkgName, version: installedVersion });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not declared in root deps → this is a transitive dependency (installed
|
||||
// because a non-root package requires it). Transitive deps are valid even
|
||||
// if absent from root declarations. Skip the range check too — we have no
|
||||
// declared range to compare against. npm's own "extraneous: true" marker
|
||||
// (handled above) is the authoritative signal for unwanted packages.
|
||||
if (!declaredRanges[pkgName]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Declared — check version satisfies range.
|
||||
var declaredRange = declaredRanges[pkgName];
|
||||
if (!satisfies(installedVersion, declaredRange)) {
|
||||
invalids.push({
|
||||
name: pkgName,
|
||||
version: installedVersion,
|
||||
declared: declaredRange,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for declared deps that have no lockfile entry (MISSING).
|
||||
var declaredNames = Object.keys(declaredRanges);
|
||||
for (var mi = 0; mi < declaredNames.length; mi++) {
|
||||
var mname = declaredNames[mi];
|
||||
var lockKey = 'node_modules/' + mname;
|
||||
if (!packages[lockKey]) {
|
||||
missings.push({ name: mname, required: declaredRanges[mname] });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Verdict ----------------------------------------------------------------
|
||||
|
||||
var failInvalid = invalids.length > 0;
|
||||
var failMissing = missings.length > 0;
|
||||
var failExtra = !ignoreExtraneous && extraneousFound.length > 0;
|
||||
|
||||
if (!failInvalid && !failMissing && !failExtra) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
var lines = [];
|
||||
lines.push('FAIL: dependency integrity drift detected');
|
||||
lines.push('');
|
||||
|
||||
if (failInvalid) {
|
||||
lines.push(' INVALID (installed version does not satisfy declared range):');
|
||||
for (var j = 0; j < invalids.length; j++) {
|
||||
var iv = invalids[j];
|
||||
lines.push(' ' + iv.name + ': declared=' + iv.declared + ' installed=' + iv.version);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (failMissing) {
|
||||
lines.push(' MISSING (declared but absent from lockfile packages map):');
|
||||
for (var k = 0; k < missings.length; k++) {
|
||||
var mv = missings[k];
|
||||
lines.push(' ' + mv.name + '@' + mv.required);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (failExtra) {
|
||||
lines.push(' EXTRANEOUS (in lockfile but not declared as a dependency):');
|
||||
for (var l = 0; l < extraneousFound.length; l++) {
|
||||
var ev = extraneousFound[l];
|
||||
lines.push(' ' + ev.name + '@' + ev.version);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('Remediation: rm -rf node_modules && npm ci');
|
||||
process.stderr.write(lines.join('\n') + '\n');
|
||||
process.exit(1);
|
||||
ENDPARSER
|
||||
|
||||
PARSE_EXIT=0
|
||||
node "$GATE_TMP/parser.js" "$LOCKFILE" "$IGNORE_EXTRANEOUS" 2>"$GATE_TMP/parse_err" || PARSE_EXIT=$?
|
||||
|
||||
# Forward parser stderr to our stderr
|
||||
if [ -s "$GATE_TMP/parse_err" ]; then
|
||||
cat "$GATE_TMP/parse_err" >&2
|
||||
fi
|
||||
|
||||
if [ "$PARSE_EXIT" -eq 0 ]; then
|
||||
echo "$SCRIPT_NAME: clean" >&2
|
||||
fi
|
||||
|
||||
exit "$PARSE_EXIT"
|
||||
16
scripts/ci-guard-runner.cjs
Normal file
16
scripts/ci-guard-runner.cjs
Normal file
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
// ci-guard-runner.cjs — Assert the current runner is github-hosted.
|
||||
// Replaces the inline bash "Guard — require GitHub-hosted runner" step.
|
||||
// Shell-agnostic: invoked as `node scripts/ci-guard-runner.cjs` from any shell.
|
||||
//
|
||||
// Exit 0 = github-hosted runner confirmed.
|
||||
// Exit 1 = not a github-hosted runner (emits GitHub Actions error annotation).
|
||||
|
||||
const env = process.env.RUNNER_ENVIRONMENT || '';
|
||||
|
||||
if (env !== 'github-hosted') {
|
||||
process.stderr.write(
|
||||
`::error::Expected github-hosted runner. RUNNER_ENVIRONMENT=${env || 'unset'}\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
46
scripts/ci-prepare-test-scope.cjs
Normal file
46
scripts/ci-prepare-test-scope.cjs
Normal file
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
// ci-prepare-test-scope.cjs — Write .ci-selected-tests.txt for scoped CI runs.
|
||||
// Replaces the inline bash "Prepare scoped test list" step.
|
||||
// Shell-agnostic: invoked as `node scripts/ci-prepare-test-scope.cjs` from any shell.
|
||||
//
|
||||
// Required environment variables (set by the workflow step's `env:` block):
|
||||
// TEST_SCOPE — "windows" | "targeted"
|
||||
// TARGETED_TESTS — space-separated test file list (from ci-test-scope.cjs output)
|
||||
// WINDOWS_TESTS — space-separated test file list for the windows lane
|
||||
//
|
||||
// Writes: .ci-selected-tests.txt (one file per line, no blanks)
|
||||
// Exit 0 = success; exit 1 = unknown scope.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const scope = process.env.TEST_SCOPE || '';
|
||||
const targeted = process.env.TARGETED_TESTS || '';
|
||||
const windows = process.env.WINDOWS_TESTS || '';
|
||||
|
||||
const FALLBACK = 'tests/command-contract.test.cjs tests/commands.test.cjs tests/core.test.cjs tests/package-manifest.test.cjs';
|
||||
|
||||
let selected;
|
||||
if (scope === 'windows') {
|
||||
selected = windows;
|
||||
} else if (scope === 'targeted') {
|
||||
selected = targeted;
|
||||
} else {
|
||||
process.stderr.write(`::error::Unknown test scope: ${scope}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Trim and fall back to default set if empty.
|
||||
if (!selected.trim()) {
|
||||
selected = FALLBACK;
|
||||
}
|
||||
|
||||
// Split on whitespace, filter blanks, join with newlines.
|
||||
const lines = selected.split(/\s+/).filter(Boolean);
|
||||
const content = lines.join('\n') + '\n';
|
||||
|
||||
const outPath = path.join(process.cwd(), '.ci-selected-tests.txt');
|
||||
fs.writeFileSync(outPath, content, 'utf-8');
|
||||
|
||||
process.stdout.write('Scoped tests:\n');
|
||||
process.stdout.write(content);
|
||||
85
scripts/ci-rebase-check.cjs
Normal file
85
scripts/ci-rebase-check.cjs
Normal file
@@ -0,0 +1,85 @@
|
||||
'use strict';
|
||||
// ci-rebase-check.cjs — Merge the PR base branch into the current PR head.
|
||||
// Replaces the inline bash "Rebase check — merge PR base branch into PR head" step.
|
||||
// Shell-agnostic: invoked as `node scripts/ci-rebase-check.cjs` from any shell.
|
||||
//
|
||||
// Required environment variables (set by the workflow step's `env:` block):
|
||||
// GITHUB_TOKEN — access token for remote set-url
|
||||
// GITHUB_BASE_REF — PR base branch name (set by GitHub Actions on pull_request events)
|
||||
// GITHUB_REPOSITORY — owner/repo (set by GitHub Actions)
|
||||
//
|
||||
// Exit 0 = merged cleanly (or merge was a no-op).
|
||||
// Exit 1 = merge conflict or fetch failure.
|
||||
|
||||
const { execFileSync, execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
function run(cmd, args, opts) {
|
||||
try {
|
||||
execFileSync(cmd, args, { stdio: 'inherit', ...opts });
|
||||
return true; // success sentinel; execFileSync returns null with stdio:'inherit'
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runOrThrow(cmd, args, label) {
|
||||
try {
|
||||
execFileSync(cmd, args, { stdio: 'inherit' });
|
||||
} catch (e) {
|
||||
process.stderr.write(`::error::${label} failed\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const token = process.env.GITHUB_TOKEN || '';
|
||||
const baseBranch = process.env.GITHUB_BASE_REF || 'main';
|
||||
const repo = process.env.GITHUB_REPOSITORY || '';
|
||||
|
||||
// Configure git identity (needed for merge commit).
|
||||
runOrThrow('git', ['config', 'user.email', 'ci@gsd-redux'], 'git config user.email');
|
||||
runOrThrow('git', ['config', 'user.name', 'CI Rebase Check'], 'git config user.name');
|
||||
|
||||
// Set authenticated remote URL.
|
||||
if (token && repo) {
|
||||
runOrThrow(
|
||||
'git',
|
||||
['remote', 'set-url', 'origin', `https://x-access-token:${token}@github.com/${repo}.git`],
|
||||
'git remote set-url'
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch base branch with retry.
|
||||
let fetched = false;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const result = run('git', ['fetch', 'origin', baseBranch]);
|
||||
if (result) {
|
||||
fetched = true;
|
||||
break;
|
||||
}
|
||||
if (attempt === 3) {
|
||||
process.stderr.write(`::error::git fetch origin ${baseBranch} failed after 3 attempts.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Wait before retry: attempt * 4 seconds.
|
||||
const waitMs = attempt * 4000;
|
||||
const deadline = Date.now() + waitMs;
|
||||
while (Date.now() < deadline) { /* busy wait, acceptable in CI */ }
|
||||
}
|
||||
|
||||
// Attempt merge.
|
||||
try {
|
||||
execFileSync('git', ['merge', '--no-edit', '--no-ff', `origin/${baseBranch}`], { stdio: 'inherit' });
|
||||
} catch (e) {
|
||||
process.stderr.write(
|
||||
`::error::This PR cannot cleanly merge origin/${baseBranch}. Rebase your branch onto current ${baseBranch} and push again.\n`
|
||||
);
|
||||
process.stderr.write('::error::Conflicting files:\n');
|
||||
try {
|
||||
execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], { stdio: 'inherit' });
|
||||
} catch (_) { /* ignore */ }
|
||||
try {
|
||||
execFileSync('git', ['merge', '--abort'], { stdio: 'inherit' });
|
||||
} catch (_) { /* ignore */ }
|
||||
process.exit(1);
|
||||
}
|
||||
27
scripts/ci-smoke-skip.cjs
Normal file
27
scripts/ci-smoke-skip.cjs
Normal file
@@ -0,0 +1,27 @@
|
||||
'use strict';
|
||||
// ci-smoke-skip.cjs — Set the "skip" output for full-only matrix entries on PR events.
|
||||
// Replaces the inline bash "Skip full-only matrix entry on PR" step.
|
||||
// Shell-agnostic: invoked as `node scripts/ci-smoke-skip.cjs` from any shell.
|
||||
//
|
||||
// Required environment variables (set by the workflow step's `env:` block):
|
||||
// EVENT — github.event_name value
|
||||
// FULL_ONLY — matrix.full_only value ("true" | "false")
|
||||
//
|
||||
// Writes to GITHUB_OUTPUT:
|
||||
// skip=true if EVENT == "pull_request" AND FULL_ONLY == "true"
|
||||
// skip=false otherwise
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
const event = process.env.EVENT || '';
|
||||
const fullOnly = process.env.FULL_ONLY || '';
|
||||
const output = process.env.GITHUB_OUTPUT || '';
|
||||
|
||||
const skip = (event === 'pull_request' && fullOnly === 'true') ? 'true' : 'false';
|
||||
|
||||
if (output) {
|
||||
fs.appendFileSync(output, `skip=${skip}\n`, 'utf-8');
|
||||
} else {
|
||||
// Fallback for local testing without GITHUB_OUTPUT set.
|
||||
process.stdout.write(`skip=${skip}\n`);
|
||||
}
|
||||
@@ -29,8 +29,8 @@ const RULES = [
|
||||
{
|
||||
name: 'environment and dependency gates',
|
||||
match: path => [
|
||||
'scripts/check-env.sh',
|
||||
'scripts/check-npm-integrity.sh',
|
||||
'scripts/check-env.cjs',
|
||||
'scripts/check-npm-integrity.cjs',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
].includes(path),
|
||||
|
||||
445
scripts/workflow-policy.cjs
Normal file
445
scripts/workflow-policy.cjs
Normal file
@@ -0,0 +1,445 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Policy: native shell per OS
|
||||
// ---------------------------------------------------------------------------
|
||||
const POLICY = Object.freeze({
|
||||
'ubuntu-latest': 'bash',
|
||||
'ubuntu-22.04': 'bash',
|
||||
'ubuntu-24.04': 'bash',
|
||||
'macos-latest': 'zsh',
|
||||
'macos-13': 'zsh',
|
||||
'macos-14': 'zsh',
|
||||
'macos-15': 'zsh',
|
||||
'windows-latest': 'pwsh',
|
||||
'windows-2022': 'pwsh',
|
||||
'windows-2025': 'pwsh',
|
||||
});
|
||||
|
||||
const VIOLATION = Object.freeze({
|
||||
WRONG_SHELL_FOR_OS: 'wrong_shell_for_os',
|
||||
MACOS_MISSING_EXPLICIT_ZSH: 'macos_missing_explicit_zsh',
|
||||
UNKNOWN_RUNNER: 'unknown_runner',
|
||||
UNRESOLVABLE_MATRIX: 'unresolvable_matrix',
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runner default (GitHub Actions documented defaults, not policy)
|
||||
// ---------------------------------------------------------------------------
|
||||
function runnerDefault(runner) {
|
||||
if (!runner) return null;
|
||||
if (runner.startsWith('windows-')) return 'pwsh';
|
||||
return 'bash'; // ubuntu-* and macos-* both default to bash on GHA
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Matrix expansion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Expand a runs-on expression against a job's strategy.matrix.
|
||||
* Returns an array of { runner: string, resolvable: boolean, context: object }
|
||||
* objects where `context` holds ALL key→value pairs for the realization row
|
||||
* (so shell expressions like ${{ matrix.shell }} can be resolved against it).
|
||||
* 'resolvable: false' means the expression was an unresolved matrix ref.
|
||||
*/
|
||||
function expandRunsOn(runsOnRaw, matrix) {
|
||||
if (!runsOnRaw) return [];
|
||||
|
||||
const raw = String(runsOnRaw).trim();
|
||||
|
||||
// Detect matrix expression: ${{ matrix.X }} or ${{ matrix['X'] }}
|
||||
const matrixExprRe = /\$\{\{\s*matrix\.(\w+)\s*\}\}/;
|
||||
const match = raw.match(matrixExprRe);
|
||||
|
||||
if (!match) {
|
||||
// Literal runner label
|
||||
return [{ runner: raw, resolvable: true, context: {} }];
|
||||
}
|
||||
|
||||
const key = match[1];
|
||||
|
||||
if (!matrix) {
|
||||
return [{ runner: raw, resolvable: false, context: {} }];
|
||||
}
|
||||
|
||||
const realizations = [];
|
||||
|
||||
// matrix.include entries carry complete row context — prefer them as they
|
||||
// contain all keys (os, node-version, shell, full_only, etc.).
|
||||
// Each include row is a distinct CI realization and must be validated
|
||||
// independently — even if two rows share the same runner label, their
|
||||
// contexts (and therefore effective shells) may differ.
|
||||
if (Array.isArray(matrix.include)) {
|
||||
for (const entry of matrix.include) {
|
||||
if (entry && entry[key] != null) {
|
||||
const runner = String(entry[key]);
|
||||
// Clone all keys from include row as the realization context
|
||||
const context = {};
|
||||
for (const [k, v] of Object.entries(entry)) {
|
||||
context[k] = v != null ? String(v) : '';
|
||||
}
|
||||
realizations.push({ runner, resolvable: true, context });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect values from matrix.<key> list (e.g. matrix.os: [ubuntu, macos])
|
||||
// These base-list entries have no extra context beyond the key itself.
|
||||
// Each entry is pushed unconditionally — deduplicating by runner alone
|
||||
// would collapse distinct Cartesian rows (e.g. duplicate os values paired
|
||||
// with different shell values) and hide policy violations on later rows.
|
||||
if (Array.isArray(matrix[key])) {
|
||||
for (const val of matrix[key]) {
|
||||
const runner = String(val);
|
||||
realizations.push({ runner, resolvable: true, context: { [key]: runner } });
|
||||
}
|
||||
}
|
||||
|
||||
// matrix.exclude: remove matches
|
||||
if (Array.isArray(matrix.exclude)) {
|
||||
for (const excl of matrix.exclude) {
|
||||
if (excl && excl[key] != null) {
|
||||
const exclRunner = String(excl[key]);
|
||||
const idx = realizations.findIndex(r => r.runner === exclRunner);
|
||||
if (idx !== -1) realizations.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (realizations.length === 0) {
|
||||
// Could not resolve — no concrete values found
|
||||
return [{ runner: raw, resolvable: false, context: {} }];
|
||||
}
|
||||
|
||||
return realizations;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Matrix expression resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* If `expr` is a `${{ matrix.<key> }}` expression, look up the value in
|
||||
* `realizationContext` (a plain-object snapshot of one matrix.include row).
|
||||
* Returns:
|
||||
* { resolved: true, value: string } — expression resolved to a concrete value
|
||||
* { resolved: false, key: string } — matrix key absent in this realization
|
||||
* null — `expr` is not a matrix expression
|
||||
*/
|
||||
function resolveMatrixExpr(expr, realizationContext) {
|
||||
if (!expr || typeof expr !== 'string') return null;
|
||||
const m = expr.match(/^\s*\$\{\{\s*matrix\.(\w+)\s*\}\}\s*$/);
|
||||
if (!m) return null;
|
||||
const key = m[1];
|
||||
if (!realizationContext || !(key in realizationContext)) {
|
||||
return { resolved: false, key };
|
||||
}
|
||||
return { resolved: true, value: String(realizationContext[key]) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effective-shell resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Given a step's shell, job defaults, workflow defaults, runner, and the
|
||||
* current matrix realization context, return the effective shell that will
|
||||
* actually execute.
|
||||
*
|
||||
* Matrix expressions (`${{ matrix.shell }}`) in any shell field are resolved
|
||||
* against `realizationContext` (a plain object of key→value for the current
|
||||
* matrix.include row).
|
||||
*
|
||||
* Returns:
|
||||
* { shell: string, unresolvable: false } — concrete shell value
|
||||
* { shell: null, unresolvable: true, key: string } — matrix expr present but key missing
|
||||
*/
|
||||
function effectiveShell(stepShell, jobDefaultsShell, workflowDefaultsShell, runner, realizationContext) {
|
||||
for (const raw of [stepShell, jobDefaultsShell, workflowDefaultsShell]) {
|
||||
if (!raw) continue;
|
||||
const mx = resolveMatrixExpr(raw, realizationContext);
|
||||
if (mx !== null) {
|
||||
// It's a matrix expression
|
||||
if (!mx.resolved) {
|
||||
return { shell: null, unresolvable: true, key: mx.key };
|
||||
}
|
||||
return { shell: mx.value, unresolvable: false };
|
||||
}
|
||||
// Literal value
|
||||
return { shell: raw, unresolvable: false };
|
||||
}
|
||||
// Nothing set at any level — use runner default
|
||||
return { shell: runnerDefault(runner), unresolvable: false };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Violation detection
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Determines whether a step/runner combination violates shell policy.
|
||||
*
|
||||
* `rawStepShell`, `rawJobDefaultsShell`, `rawWorkflowDefaultsShell` are the
|
||||
* raw (possibly matrix-expression) values before resolution. They're used
|
||||
* only for the MACOS_MISSING_EXPLICIT_ZSH sub-classification: that violation
|
||||
* fires only when nothing is set at any level (all three are null/empty AND
|
||||
* the runner default is wrong).
|
||||
*/
|
||||
function detectViolation(runner, resolvedShell, rawStepShell, rawJobDefaultsShell, rawWorkflowDefaultsShell) {
|
||||
if (!(runner in POLICY)) {
|
||||
return VIOLATION.UNKNOWN_RUNNER;
|
||||
}
|
||||
const expected = POLICY[runner];
|
||||
if (resolvedShell !== expected) {
|
||||
// Specific subtype for macOS missing explicit zsh:
|
||||
// fires only when no shell is set at any level (inherited runner default).
|
||||
if (runner.startsWith('macos-') && !rawStepShell && !rawJobDefaultsShell && !rawWorkflowDefaultsShell) {
|
||||
return VIOLATION.MACOS_MISSING_EXPLICIT_ZSH;
|
||||
}
|
||||
return VIOLATION.WRONG_SHELL_FOR_OS;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source map: find line numbers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find the line number of a string in YAML text.
|
||||
* Returns 1-based line number of the first occurrence at or after startLine.
|
||||
*/
|
||||
function findLineNumber(yamlText, searchStr, startLine) {
|
||||
const lines = yamlText.split('\n');
|
||||
const start = Math.max(0, (startLine || 1) - 1);
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
if (lines[i].includes(searchStr)) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
// Fall back to scanning from beginning
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(searchStr)) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core inspector
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* inspectWorkflow(yamlText, { filePath }) → structured inspection result
|
||||
*/
|
||||
function inspectWorkflow(yamlText, { filePath = '<unknown>' } = {}) {
|
||||
let doc;
|
||||
try {
|
||||
doc = yaml.load(yamlText, { schema: yaml.DEFAULT_SCHEMA });
|
||||
} catch (e) {
|
||||
return {
|
||||
filePath,
|
||||
jobs: [],
|
||||
workflowDefaultsShell: null,
|
||||
parseError: e.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (!doc || typeof doc !== 'object') {
|
||||
return { filePath, jobs: [], workflowDefaultsShell: null };
|
||||
}
|
||||
|
||||
const workflowDefaultsShell =
|
||||
doc.defaults?.run?.shell ?? null;
|
||||
|
||||
const jobs = [];
|
||||
|
||||
for (const [jobId, jobDef] of Object.entries(doc.jobs || {})) {
|
||||
if (!jobDef || typeof jobDef !== 'object') continue;
|
||||
|
||||
const runsOnRaw = jobDef['runs-on'];
|
||||
const matrix = jobDef.strategy?.matrix ?? null;
|
||||
const jobDefaultsShell = jobDef.defaults?.run?.shell ?? null;
|
||||
|
||||
const runsOnStr = runsOnRaw != null ? String(runsOnRaw) : '';
|
||||
const runsOnExpressions = [runsOnStr];
|
||||
const runnerRealizations = expandRunsOn(runsOnStr, matrix);
|
||||
|
||||
const steps = [];
|
||||
|
||||
for (const [stepIndex, step] of (jobDef.steps || []).entries()) {
|
||||
if (!step || typeof step !== 'object') continue;
|
||||
|
||||
// Only check steps that actually run shell scripts (have `run:`)
|
||||
if (!step.run) continue;
|
||||
|
||||
const stepShell = step.shell ?? null;
|
||||
const stepName = step.name ?? `step-${stepIndex}`;
|
||||
|
||||
for (const { runner, resolvable, context: realizationContext } of runnerRealizations) {
|
||||
if (!resolvable) {
|
||||
// Can't resolve runner — emit UNRESOLVABLE_MATRIX
|
||||
const lineNum = findLineNumber(yamlText, stepName !== `step-${stepIndex}` ? stepName : String(step.run).slice(0, 20));
|
||||
steps.push({
|
||||
index: stepIndex,
|
||||
name: stepName,
|
||||
stepShell,
|
||||
effectiveShell: null,
|
||||
runner,
|
||||
violation: VIOLATION.UNRESOLVABLE_MATRIX,
|
||||
evidence: {
|
||||
line: lineNum,
|
||||
snippet: `runs-on: ${runsOnStr} (unresolvable matrix expression)`,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const effResult = effectiveShell(stepShell, jobDefaultsShell, workflowDefaultsShell, runner, realizationContext);
|
||||
|
||||
// If a matrix expression referenced a key not present in this realization row
|
||||
if (effResult.unresolvable) {
|
||||
const lineNum = findLineNumber(yamlText, stepName !== `step-${stepIndex}` ? stepName : String(step.run).slice(0, 20));
|
||||
steps.push({
|
||||
index: stepIndex,
|
||||
name: stepName,
|
||||
stepShell,
|
||||
effectiveShell: null,
|
||||
runner,
|
||||
violation: VIOLATION.UNRESOLVABLE_MATRIX,
|
||||
evidence: {
|
||||
line: lineNum,
|
||||
snippet: `matrix.${effResult.key} not present in realization for runner=${runner}`,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const eff = effResult.shell;
|
||||
const violation = detectViolation(runner, eff, stepShell, jobDefaultsShell, workflowDefaultsShell);
|
||||
|
||||
// Find evidence line: prefer step name, then shell:, then run: content
|
||||
let evidenceLine = 1;
|
||||
let evidenceSnippet = '';
|
||||
|
||||
if (stepName !== `step-${stepIndex}`) {
|
||||
evidenceLine = findLineNumber(yamlText, stepName);
|
||||
evidenceSnippet = `name: ${stepName}`;
|
||||
} else if (stepShell) {
|
||||
evidenceLine = findLineNumber(yamlText, `shell: ${stepShell}`);
|
||||
evidenceSnippet = `shell: ${stepShell}`;
|
||||
} else {
|
||||
const runSnippet = String(step.run).split('\n')[0].slice(0, 40);
|
||||
evidenceLine = findLineNumber(yamlText, runSnippet);
|
||||
evidenceSnippet = runSnippet;
|
||||
}
|
||||
|
||||
steps.push({
|
||||
index: stepIndex,
|
||||
name: stepName,
|
||||
stepShell,
|
||||
effectiveShell: eff,
|
||||
runner,
|
||||
violation: violation ?? null,
|
||||
evidence: {
|
||||
line: evidenceLine,
|
||||
snippet: evidenceSnippet,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedRunners = runnerRealizations
|
||||
.filter(r => r.resolvable)
|
||||
.map(r => r.runner);
|
||||
|
||||
jobs.push({
|
||||
jobId,
|
||||
runsOnExpressions,
|
||||
resolvedRunners,
|
||||
defaultsShell: jobDefaultsShell,
|
||||
steps,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
jobs,
|
||||
workflowDefaultsShell,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* inspectWorkflowFile(absPath) — reads file from disk and calls inspectWorkflow.
|
||||
*/
|
||||
function inspectWorkflowFile(absPath) {
|
||||
const text = fs.readFileSync(absPath, 'utf8');
|
||||
return inspectWorkflow(text, { filePath: absPath });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// runPolicyLint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* runPolicyLint({ workflowsDir }) → { violations, summary }
|
||||
*/
|
||||
function runPolicyLint({ workflowsDir }) {
|
||||
const absDir = path.resolve(workflowsDir);
|
||||
const files = fs.readdirSync(absDir)
|
||||
.filter(f => f.endsWith('.yml') || f.endsWith('.yaml'))
|
||||
.map(f => path.join(absDir, f))
|
||||
.sort();
|
||||
|
||||
const violations = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
const result = inspectWorkflowFile(filePath);
|
||||
for (const job of result.jobs) {
|
||||
for (const step of job.steps) {
|
||||
if (step.violation) {
|
||||
violations.push({
|
||||
filePath: result.filePath,
|
||||
jobId: job.jobId,
|
||||
stepIndex: step.index,
|
||||
stepName: step.name,
|
||||
runner: step.runner,
|
||||
effectiveShell: step.effectiveShell,
|
||||
stepShell: step.stepShell,
|
||||
violation: step.violation,
|
||||
evidence: step.evidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const perViolationType = {};
|
||||
for (const v of violations) {
|
||||
perViolationType[v.violation] = (perViolationType[v.violation] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
violations,
|
||||
summary: {
|
||||
total: violations.length,
|
||||
perViolationType,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
module.exports = {
|
||||
POLICY,
|
||||
VIOLATION,
|
||||
inspectWorkflow,
|
||||
inspectWorkflowFile,
|
||||
runPolicyLint,
|
||||
};
|
||||
Reference in New Issue
Block a user