From 2e04a7a4af0cd26e5c4c57d4b5d5cbbc3fc0a1fd Mon Sep 17 00:00:00 2001 From: Pablo Ilundain Date: Wed, 19 Aug 2026 19:58:56 +0000 Subject: [PATCH] feat(pr-checks): reject unbounded dependency ranges in Node PR checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a check-dependency-ranges composite action that fails when package.json declares a version range with no upper bound (*, latest, x, or a bare >= / > comparator without a < companion), and wires it into the two Node testing reusables (npm and pnpm) before dependency install. Motivation: a security override written as a bare ">=" floated a CommonJS consumer onto an ESM-only major and crashed a service at init with ERR_REQUIRE_ESM, while CI stayed green because it tested on a newer Node than the deployed runtime. Unbounded ranges tend to arrive from hurried security bumps or copy-pasted examples, not from deliberate versioning decisions. Scope decisions: - dependencies, devDependencies, optionalDependencies and overrides (including nested override objects) are checked; peerDependencies are NOT — open-ended peer ranges are a legitimate library convention, bounded by the consumer. - Non-registry specifiers (git/file/link/workspace/catalog protocols) are skipped, npm: aliases are judged by their aliased range, and each || alternative is judged on its own. - pr-checks-node-pnpm-build is intentionally untouched: it is a build-only workflow, and PR gating belongs to the testing reusables. The step is on by default; a repo that needs time to fix existing ranges can pass check-dependency-ranges: false to the reusable workflow. --- .../check-dependency-ranges/action.yml | 13 +++ .../check-version-ranges.mjs | 79 +++++++++++++++++++ .github/workflows/pr-checks-node-npm.yml | 11 +++ .github/workflows/pr-checks-node-pnpm.yml | 11 +++ 4 files changed, 114 insertions(+) create mode 100644 .github/actions/check-dependency-ranges/action.yml create mode 100644 .github/actions/check-dependency-ranges/check-version-ranges.mjs diff --git a/.github/actions/check-dependency-ranges/action.yml b/.github/actions/check-dependency-ranges/action.yml new file mode 100644 index 0000000..808bd07 --- /dev/null +++ b/.github/actions/check-dependency-ranges/action.yml @@ -0,0 +1,13 @@ +name: Check dependency version ranges +description: Fails when package.json declares version ranges with no upper bound (*, latest, bare >= / >) +inputs: + working-directory: + required: false + default: '.' +runs: + using: composite + steps: + - shell: sh + env: + WORKING_DIR: ${{ inputs.working-directory }} + run: node "$GITHUB_ACTION_PATH/check-version-ranges.mjs" "$WORKING_DIR/package.json" diff --git a/.github/actions/check-dependency-ranges/check-version-ranges.mjs b/.github/actions/check-dependency-ranges/check-version-ranges.mjs new file mode 100644 index 0000000..86dbba7 --- /dev/null +++ b/.github/actions/check-dependency-ranges/check-version-ranges.mjs @@ -0,0 +1,79 @@ +// Fails when package.json declares dependency ranges with no upper bound. +// An uncapped range silently adopts future majors, breaking changes included: +// a ">=" security override can float a transitive dependency onto an ESM-only +// major and break a CommonJS consumer at runtime, with CI still green. +import { readFileSync } from 'fs'; + +// peerDependencies are deliberately NOT checked: open-ended peer ranges are a +// legitimate library convention — the consumer's own dependency range bounds them. +const DEPENDENCY_SECTIONS = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'overrides', +]; + +// Specifiers that are not semver ranges (git URLs, local paths, workspace and +// catalog protocols) are skipped — the check only judges registry version ranges. +const NON_RANGE_SPECIFIER = /^(file:|git|http|link:|workspace:|catalog:)/; + +// An alternative is unbounded when nothing caps it from above: a bare "*", +// "x", "latest", or a ">"/">=" comparator with no "<"/"<=" companion. +const isUnboundedAlternative = (alternative) => { + const trimmed = alternative.trim(); + if (trimmed === '' || trimmed === '*' || trimmed === 'x' || trimmed === 'latest') { + return true; + } + if (trimmed.includes('>') && !trimmed.includes('<')) { + return true; + } + return false; +}; + +const isUnboundedRange = (range) => { + if (NON_RANGE_SPECIFIER.test(range)) { + return false; + } + // npm: aliases delegate to the aliased range ("npm:package@^1.0.0") + const effectiveRange = range.startsWith('npm:') ? range.slice(range.lastIndexOf('@') + 1) : range; + return effectiveRange.split('||').some(isUnboundedAlternative); +}; + +export const findUnboundedRanges = (packageDefinition) => { + const findings = []; + const inspect = (section, entries) => { + for (const [name, specifier] of Object.entries(entries)) { + if (typeof specifier === 'object' && specifier !== null) { + // overrides admit nested objects ({".": "...", child: "..."}) + inspect(`${section} > ${name}`, specifier); + } + if (typeof specifier === 'string' && isUnboundedRange(specifier)) { + findings.push({ section, name, specifier }); + } + } + }; + for (const section of DEPENDENCY_SECTIONS) { + if (packageDefinition[section]) { + inspect(section, packageDefinition[section]); + } + } + return findings; +}; + +const isMainModule = process.argv[1]?.endsWith('check-version-ranges.mjs'); +if (isMainModule) { + const packageJsonPath = process.argv[2] ?? 'package.json'; + const packageDefinition = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + const findings = findUnboundedRanges(packageDefinition); + if (findings.length > 0) { + console.error('Unbounded version ranges are not allowed — an uncapped range adopts'); + console.error('future majors silently, including breaking ones: a ">=" override can'); + console.error('float a dependency onto an ESM-only major and break a CommonJS consumer.'); + for (const { section, name, specifier } of findings) { + console.error(` ${section}: "${name}": "${specifier}" — pin a ceiling (^, ~ or an exact version)`); + } + console.error('To bypass while fixing: pass check-dependency-ranges: false to the reusable workflow.'); + process.exit(1); + } + console.log('All dependency ranges have an upper bound.'); +} diff --git a/.github/workflows/pr-checks-node-npm.yml b/.github/workflows/pr-checks-node-npm.yml index 37b1e79..a911ccf 100644 --- a/.github/workflows/pr-checks-node-npm.yml +++ b/.github/workflows/pr-checks-node-npm.yml @@ -13,6 +13,11 @@ on: required: false type: string default: '' + check-dependency-ranges: + description: 'Fail when package.json has unbounded version ranges (*, latest, bare >= / >)' + required: false + type: boolean + default: true permissions: contents: read @@ -44,6 +49,12 @@ jobs: cache: 'npm' cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json + - name: Check dependency ranges + if: inputs.check-dependency-ranges + uses: nullplatform/actions-nullplatform/.github/actions/check-dependency-ranges@main + with: + working-directory: ${{ inputs.working-directory }} + - name: Install dependencies working-directory: ${{ inputs.working-directory }} run: npm ci diff --git a/.github/workflows/pr-checks-node-pnpm.yml b/.github/workflows/pr-checks-node-pnpm.yml index 326cac4..5eba7cb 100644 --- a/.github/workflows/pr-checks-node-pnpm.yml +++ b/.github/workflows/pr-checks-node-pnpm.yml @@ -13,6 +13,11 @@ on: required: false type: string default: '' + check-dependency-ranges: + description: 'Fail when package.json has unbounded version ranges (*, latest, bare >= / >)' + required: false + type: boolean + default: true permissions: contents: read @@ -47,6 +52,12 @@ jobs: cache: 'pnpm' cache-dependency-path: ${{ inputs.working-directory }}/pnpm-lock.yaml + - name: Check dependency ranges + if: inputs.check-dependency-ranges + uses: nullplatform/actions-nullplatform/.github/actions/check-dependency-ranges@main + with: + working-directory: ${{ inputs.working-directory }} + - name: Install dependencies working-directory: ${{ inputs.working-directory }} run: pnpm install