From 2c4805cf4c12f52bac5c7e31b7ac7cc8c6f5d17c Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Mon, 27 Jul 2026 09:01:43 +0200 Subject: [PATCH] test(core): Guard against private packages in the published runtime graph (#34889) Co-authored-by: Claude Opus 4.8 (1M context) --- lefthook.yml | 6 ++ package.json | 3 +- scripts/check-workspace-private-deps.mjs | 89 ++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 scripts/check-workspace-private-deps.mjs diff --git a/lefthook.yml b/lefthook.yml index b06a5c5c8af..a427ff331ab 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -32,6 +32,12 @@ pre-commit: skip: - merge - rebase + workspace_private_deps_check: + glob: '**/package.json' + run: node scripts/check-workspace-private-deps.mjs + skip: + - merge + - rebase zod_peer_deps_check: glob: 'packages/{@n8n/api-types,workflow,core,@n8n/agents}/package.json' run: node scripts/check-zod-peer-deps.mjs diff --git a/package.json b/package.json index 6560e349f29..653bdbe7399 100644 --- a/package.json +++ b/package.json @@ -42,13 +42,14 @@ "lint:styles:fix": "turbo run lint:styles:fix", "lint:affected": "turbo run lint --affected", "lint:fix": "turbo run lint:fix", - "lint:ci": "turbo run lint lint:styles && pnpm boundaries:check", + "lint:ci": "turbo run lint lint:styles && pnpm boundaries:check && pnpm check:workspace-private-deps", "boundaries": "turbo boundaries", "boundaries:check": "node scripts/check-boundaries.mjs", "boundaries:baseline": "node scripts/check-boundaries.mjs --write", "sync:skill-links": "node scripts/sync-agent-skill-links.mjs", "check:skill-links": "node scripts/sync-agent-skill-links.mjs --check", "check:zod-peer-deps": "node scripts/check-zod-peer-deps.mjs", + "check:workspace-private-deps": "node scripts/check-workspace-private-deps.mjs", "optimize-svg": "find ./packages -name '*.svg' ! -name 'pipedrive.svg' -print0 | xargs -0 -P16 -L20 npx svgo", "setup-backend-module": "node scripts/ensure-zx.mjs && zx scripts/backend-module/setup.mjs", "start": "node scripts/os-normalize.mjs --dir packages/cli/bin n8n", diff --git a/scripts/check-workspace-private-deps.mjs b/scripts/check-workspace-private-deps.mjs new file mode 100644 index 00000000000..3d623c7566f --- /dev/null +++ b/scripts/check-workspace-private-deps.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node + +/** + * Guards against a broken `npm install n8n`: a publishable (non-private) + * workspace package must not depend at runtime on a `private` one. + * + * The `n8n` cli depends on `n8n-editor-ui` (published), which depended on + * `@n8n/frontend-constants`, `@n8n/frontend-utils` and `@n8n/telemetry` — all + * marked `"private": true`. Publishing `n8n` then produces an install graph + * pointing at packages that were never published. Marking any package private + * (or adding a private package as a runtime dependency of a published one) + * silently reintroduces this. + * + * We only need to check *direct* runtime deps: if every published package's + * direct workspace deps are also published, the whole runtime closure is clean + * by induction. `devDependencies` don't ship, so they're ignored. + */ + +import { globSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +/** + * Pure, testable core. Given the workspace manifests, return violation strings + * for every non-private package that has a private workspace runtime dep. + * @param {Array<{name: string, private?: boolean, dependencies?: Record}>} manifests + */ +export function findViolations(manifests) { + const byName = new Map(manifests.map((m) => [m.name, m])); + const violations = []; + for (const pkg of manifests) { + if (pkg.private) continue; // a private package may depend on anything + for (const [dep, range] of Object.entries(pkg.dependencies ?? {})) { + if (typeof range !== 'string' || !range.startsWith('workspace:')) continue; + if (byName.get(dep)?.private) { + violations.push(`${pkg.name} → ${dep} (${dep} is "private": true)`); + } + } + } + return violations; +} + +function loadManifests() { + const files = globSync('packages/**/package.json', { + cwd: root, + exclude: (p) => p.includes('node_modules'), + }); + return files.map((rel) => JSON.parse(readFileSync(resolve(root, rel), 'utf8'))).filter((m) => m.name); +} + +function main() { + const violations = findViolations(loadManifests()); + if (violations.length > 0) { + console.error(''); + console.error('ERROR: publishable packages must not depend on "private" packages at runtime.'); + console.error(''); + console.error('These packages are published to npm, so a private runtime dependency leaves the'); + console.error('published install graph pointing at packages that were never published, breaking'); + console.error('`npm install n8n`.'); + console.error(''); + for (const v of violations) console.error(` - ${v}`); + console.error(''); + console.error('Either drop "private": true from the dependency, or move it to devDependencies.'); + console.error(''); + process.exit(1); + } + console.log('OK: no publishable package depends on a private package at runtime.'); +} + +// `--self-test` exercises the core logic without touching the workspace. +if (process.argv.includes('--self-test')) { + const manifests = [ + { name: 'pub', dependencies: { priv: 'workspace:*', ok: 'workspace:*' } }, + { name: 'priv', private: true }, + { name: 'ok' }, + { name: 'priv-dep-of-priv', private: true, dependencies: { priv: 'workspace:*' } }, + { name: 'ext', dependencies: { lodash: '^4' } }, + ]; + const v = findViolations(manifests); + const pass = v.length === 1 && v[0].startsWith('pub → priv'); + console.log(pass ? 'self-test passed' : `self-test FAILED: ${JSON.stringify(v)}`); + process.exit(pass ? 0 : 1); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +}