From 10e3ff166cd30eeee3782201c30c1d244fd7f588 Mon Sep 17 00:00:00 2001 From: Ozgur Ersoy Date: Tue, 6 Oct 2026 19:46:30 +0200 Subject: [PATCH 1/2] Check @geastack dependency ranges before publishing CLI 0.1.97 requires @geastack/targets ^0.1.97, but npm only had 0.1.95. The targets repo was not checked out under the collection root, so the publish script found only cli and would have published it with a dependency nobody can install. Before anything is published, and also under --plan and --dry-run, every @geastack range in a selected package's dependencies and optionalDependencies must now be met. Either a package in the same publish has a local version that satisfies the range, or `npm view @` finds a match. Otherwise the script stops without publishing. The error says whether the dependency is in this publish with the wrong version, left out by --package/--from, or has no checkout at all. An npm query that fails for another reason, such as a timeout, also stops the publish. --skip-dep-check skips the check. peerDependencies are not checked, matching how they are left out of the publish order. publish-private-packages tests pass (12/12). Against the real collection root, --plan now reports the missing targets ^0.1.97. --- scripts/publish-private-packages.mjs | 128 +++++++++++++- test/publish-private-packages.test.mjs | 234 ++++++++++++++++++++++++- 2 files changed, 356 insertions(+), 6 deletions(-) diff --git a/scripts/publish-private-packages.mjs b/scripts/publish-private-packages.mjs index c5c18a7..688c364 100755 --- a/scripts/publish-private-packages.mjs +++ b/scripts/publish-private-packages.mjs @@ -44,8 +44,9 @@ if (options.help) { } const collectionRoot = path.resolve(options.root ?? defaultCollectionRoot) +const discoveredPackages = discoverPublishablePackages(collectionRoot) const packages = orderPackages( - discoverPublishablePackages(collectionRoot) + discoveredPackages .filter((pkg) => options.packages.length === 0 || options.packages.includes(pkg.name)) ) const selectedPackages = applyFromFilter(packages, options.from) @@ -60,6 +61,9 @@ if (selectedPackages.length === 0) fail('no publishable packages found') printPlan(selectedPackages, collectionRoot, options) +if (options.skipDepCheck) console.warn('\nskipping @geastack dependency check (--skip-dep-check)') +else verifyDependencies(selectedPackages, discoveredPackages, collectionRoot, options) + if (options.plan) process.exit(0) if (options.yes && options.dryRun) fail('use either --yes or --dry-run, not both') @@ -129,6 +133,7 @@ function parseArgs(argv) { plan: false, registry: DEFAULT_REGISTRY, root: null, + skipDepCheck: false, skipExisting: true, tag: 'alpha', yes: false, @@ -142,6 +147,7 @@ function parseArgs(argv) { else if (arg === '--yes' || arg === '--publish') options.yes = true else if (arg === '--plan') options.plan = true else if (arg === '--no-skip-existing') options.skipExisting = false + else if (arg === '--skip-dep-check') options.skipDepCheck = true else if (arg === '--root') options.root = readValue(argv, ++index, arg) else if (arg === '--registry') options.registry = readValue(argv, ++index, arg) else if (arg === '--tag') options.tag = readValue(argv, ++index, arg) @@ -179,6 +185,7 @@ Options: --package Publish only one package. Can be repeated. --from Resume from a package name in the ordered list. --no-skip-existing Do not skip versions already visible through npm view. + --skip-dep-check Do not check that @geastack dependency ranges resolve. --allow-dirty Allow real publish from dirty git worktrees. --root GeaStack collection root. Default: ${defaultCollectionRoot} @@ -186,7 +193,11 @@ Only non-private ${SCOPE} packages with a publishConfig.access of "restricted" or "public" are included, and each is published with its own access value, so flipping a package to public in its package.json is enough. Packages land under ${ORG_PACKAGES_URL}. Existing package versions are not republished; real publish -ensures their npm dist-tag with npm dist-tag add.`) +ensures their npm dist-tag with npm dist-tag add. + +Before anything is published, every @geastack dependency range of every +selected package must be met by a package in the same publish or by a version +already on npm; otherwise the script stops without publishing.`) } function discoverPublishablePackages(root) { @@ -202,6 +213,7 @@ function discoverPublishablePackages(root) { packages.push({ access: pkg.publishConfig.access, dependencies: dependencyNames(pkg), + dependencyRanges: scopedDependencyRanges(pkg), dir: path.dirname(packageJsonPath), name: pkg.name, packageJsonPath, @@ -243,6 +255,118 @@ function dependencyNames(pkg) { ]) } +function scopedDependencyRanges(pkg) { + const ranges = { ...pkg.optionalDependencies, ...pkg.dependencies } + return Object.entries(ranges).filter(([name]) => name.startsWith(SCOPE)) +} + +// A package published against an @geastack range nobody can install breaks +// every consumer: cli 0.1.97 went out needing targets ^0.1.97 while npm only had +// 0.1.95, because targets was not checked out under the collection root. Each +// range must be met by a package in this publish (published first, by order) +// or by a version npm already has. +function verifyDependencies(selected, discovered, root, options) { + const inPublish = new Map(selected.map((pkg) => [pkg.name, pkg])) + const inCollection = new Map(discovered.map((pkg) => [pkg.name, pkg])) + const npmLookups = new Map() + const problems = [] + + for (const pkg of selected) { + for (const [dep, range] of pkg.dependencyRanges) { + const local = inPublish.get(dep) + if (local && satisfies(local.version, range)) continue + + const key = `${dep}@${range}` + if (!npmLookups.has(key)) npmLookups.set(key, npmRangeResolves(dep, range, options)) + const lookup = npmLookups.get(key) + if (lookup.ok) continue + + const reasons = [lookup.reason] + if (local) reasons.push(`this publish has ${dep}@${local.version}, which does not match`) + else if (inCollection.has(dep)) reasons.push(`${dep}@${inCollection.get(dep).version} was found locally but is not selected by --package/--from`) + else reasons.push(`${dep} is not in this publish (no checkout under ${root}; pass --root or publish it first)`) + problems.push(` - ${pkg.name}@${pkg.version} needs ${key}\n${reasons.map((reason) => ` ${reason}`).join('\n')}`) + } + } + + if (problems.length > 0) { + fail(`unresolved @geastack dependencies:\n${problems.join('\n')}\npass --skip-dep-check to publish anyway`) + } + console.log('\n@geastack dependency ranges resolve.') +} + +function npmRangeResolves(name, range, options) { + const result = spawnSync( + ...spawnArgs('npm', ['view', `${name}@${range}`, 'version', '--registry', options.registry, '--json'], { + encoding: 'utf8' + }) + ) + if (result.error) return { ok: false, reason: `npm view failed: ${result.error.message}` } + if (result.status === 0 && result.stdout.trim()) return { ok: true } + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}` + if (result.status === 0 || /\bE404\b|404 Not Found|No match found|is not in this registry/i.test(output)) { + return { ok: false, reason: `npm has no version matching ${range}${latestNpmVersion(name, options)}` } + } + return { ok: false, reason: `could not query npm (exit ${result.status}): ${firstLine(result.stderr || result.stdout)}` } +} + +function latestNpmVersion(name, options) { + const result = spawnSync( + ...spawnArgs('npm', ['view', name, 'version', '--registry', options.registry, '--json'], { encoding: 'utf8' }) + ) + if (result.status !== 0) return '' + try { + const version = JSON.parse(result.stdout) + return typeof version === 'string' ? ` (latest: ${version})` : '' + } catch { + return '' + } +} + +function firstLine(text) { + return String(text ?? '').trim().split('\n')[0] ?? '' +} + +// Covers the range forms the @geastack manifests use: exact, ^, ~ and >=. +// Anything else returns false, so npm decides whether the range resolves. +function satisfies(version, range) { + const match = /^(\^|~|>=)?\s*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(range.trim()) + const have = parseVersion(version) + if (!match || !have) return false + const [, operator = '', wantText] = match + const want = parseVersion(wantText) + if (operator === '') return compareVersions(have, want) === 0 + if (compareVersions(have, want) < 0) return false + if (have.pre.length > 0 && !(have.major === want.major && have.minor === want.minor && have.patch === want.patch)) return false + if (operator === '>=') return true + if (operator === '~') return have.major === want.major && have.minor === want.minor + if (want.major > 0) return have.major === want.major + if (want.minor > 0) return have.major === 0 && have.minor === want.minor + return have.major === 0 && have.minor === 0 && have.patch === want.patch +} + +function parseVersion(text) { + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(text ?? '').trim()) + if (!match) return null + return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), pre: match[4] ? match[4].split('.') : [] } +} + +function compareVersions(a, b) { + for (const key of ['major', 'minor', 'patch']) { + if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1 + } + if (a.pre.length === 0 || b.pre.length === 0) return b.pre.length - a.pre.length + for (let index = 0; index < Math.max(a.pre.length, b.pre.length); index += 1) { + if (a.pre[index] === undefined) return -1 + if (b.pre[index] === undefined) return 1 + if (a.pre[index] === b.pre[index]) continue + const numeric = /^\d+$/.test(a.pre[index]) && /^\d+$/.test(b.pre[index]) + if (numeric) return Number(a.pre[index]) < Number(b.pre[index]) ? -1 : 1 + return a.pre[index] < b.pre[index] ? -1 : 1 + } + return 0 +} + function orderPackages(packages) { const byName = new Map() for (const pkg of packages) { diff --git a/test/publish-private-packages.test.mjs b/test/publish-private-packages.test.mjs index 5643dd2..1144b57 100644 --- a/test/publish-private-packages.test.mjs +++ b/test/publish-private-packages.test.mjs @@ -130,11 +130,236 @@ esac } }) +test('publish stops when an @geastack dependency range resolves nowhere', () => { + const { root, run, npmCommands, cleanup } = dependencyFixture({ + npmVersions: { '@geastack/targets@^0.1.97': null, '@geastack/targets': '0.1.95' } + }) + try { + writePublishable(root, 'cli/package.json', '@geastack/cli', { + version: '0.1.97', + dependencies: { '@geastack/targets': '^0.1.97', picocolors: '^1.1.1' } + }) + + const result = run(['--yes', '--allow-dirty']) + + assert.equal(result.status, 1) + assert.match(result.stderr, /@geastack\/cli@0\.1\.97 needs @geastack\/targets@\^0\.1\.97/) + assert.match(result.stderr, /npm has no version matching \^0\.1\.97 \(latest: 0\.1\.95\)/) + assert.match(result.stderr, /@geastack\/targets is not in this publish/) + assert.doesNotMatch(npmCommands(), /^publish/m) + assert.doesNotMatch(npmCommands(), /picocolors/) + } finally { + cleanup() + } +}) + +test('plan mode reports unresolved dependency ranges', () => { + const { root, run, cleanup } = dependencyFixture({ npmVersions: {} }) + try { + writePublishable(root, 'cli/package.json', '@geastack/cli', { + optionalDependencies: { '@geastack/targets': '~0.1.97' } + }) + + const result = run(['--plan']) + + assert.equal(result.status, 1) + assert.match(result.stderr, /needs @geastack\/targets@~0\.1\.97/) + } finally { + cleanup() + } +}) + +test('a dependency published in the same run satisfies the range without asking npm', () => { + const { root, run, npmCommands, cleanup } = dependencyFixture({ npmVersions: {} }) + try { + writePublishable(root, 'targets/package.json', '@geastack/targets', { version: '0.1.97' }) + writePublishable(root, 'cli/package.json', '@geastack/cli', { + dependencies: { '@geastack/targets': '^0.1.97' } + }) + + const result = run(['--plan']) + + assert.equal(result.status, 0, result.stderr) + assert.match(result.stdout, /@geastack dependency ranges resolve\./) + assert.equal(npmCommands(), '') + } finally { + cleanup() + } +}) + +test('a dependency in the same run with a non-matching version still has to resolve on npm', () => { + const { root, run, cleanup } = dependencyFixture({ npmVersions: {} }) + try { + writePublishable(root, 'targets/package.json', '@geastack/targets', { version: '0.1.95' }) + writePublishable(root, 'cli/package.json', '@geastack/cli', { + dependencies: { '@geastack/targets': '^0.1.97' } + }) + + const result = run(['--plan']) + + assert.equal(result.status, 1) + assert.match(result.stderr, /this publish has @geastack\/targets@0\.1\.95, which does not match/) + } finally { + cleanup() + } +}) + +test('a dependency left out by --package is called out', () => { + const { root, run, cleanup } = dependencyFixture({ npmVersions: {} }) + try { + writePublishable(root, 'targets/package.json', '@geastack/targets', { version: '0.1.97' }) + writePublishable(root, 'cli/package.json', '@geastack/cli', { + dependencies: { '@geastack/targets': '^0.1.97' } + }) + + const result = run(['--plan', '--package', '@geastack/cli']) + + assert.equal(result.status, 1) + assert.match(result.stderr, /@geastack\/targets@0\.1\.97 was found locally but is not selected/) + } finally { + cleanup() + } +}) + +test('a range already on npm passes', () => { + const { root, run, npmCommands, cleanup } = dependencyFixture({ + npmVersions: { '@geastack/targets@^0.1.94': '["0.1.94","0.1.95"]' } + }) + try { + writePublishable(root, 'cli/package.json', '@geastack/cli', { + dependencies: { '@geastack/targets': '^0.1.94' } + }) + + const result = run(['--plan']) + + assert.equal(result.status, 0, result.stderr) + assert.match(npmCommands(), /view @geastack\/targets@\^0\.1\.94 version/) + } finally { + cleanup() + } +}) + +test('an npm failure other than a missing version also stops the publish', () => { + const { root, run, cleanup } = dependencyFixture({ npmVersions: {}, viewFails: true }) + try { + writePublishable(root, 'cli/package.json', '@geastack/cli', { + dependencies: { '@geastack/targets': '^0.1.94' } + }) + + const result = run(['--plan']) + + assert.equal(result.status, 1) + assert.match(result.stderr, /could not query npm \(exit 1\): npm error code ETIMEDOUT/) + } finally { + cleanup() + } +}) + +test('--skip-dep-check skips the dependency check', () => { + const { root, run, npmCommands, cleanup } = dependencyFixture({ npmVersions: {} }) + try { + writePublishable(root, 'cli/package.json', '@geastack/cli', { + dependencies: { '@geastack/targets': '^0.1.97' } + }) + + const result = run(['--plan', '--skip-dep-check']) + + assert.equal(result.status, 0, result.stderr) + assert.match(result.stderr, /skipping @geastack dependency check/) + assert.equal(npmCommands(), '') + } finally { + cleanup() + } +}) + +test('caret, tilde and exact ranges follow npm semantics for local versions', () => { + const cases = [ + ['0.1.97', '^0.1.97', true], + ['0.1.98', '^0.1.97', true], + ['0.2.0', '^0.1.97', false], + ['0.0.4', '^0.0.3', false], + ['1.4.0', '^1.2.0', true], + ['2.0.0', '^1.2.0', false], + ['0.1.99', '~0.1.97', true], + ['0.2.0', '~0.1.97', false], + ['0.1.97', '0.1.97', true], + ['0.1.98', '0.1.97', false], + ['0.1.98-alpha.1', '^0.1.97', false], + ['0.3.0', '>=0.1.97', true], + ] + for (const [version, range, expected] of cases) { + const { root, run, cleanup } = dependencyFixture({ npmVersions: {} }) + try { + writePublishable(root, 'targets/package.json', '@geastack/targets', { version }) + writePublishable(root, 'cli/package.json', '@geastack/cli', { + dependencies: { '@geastack/targets': range } + }) + const result = run(['--plan']) + assert.equal(result.status === 0, expected, `${version} vs ${range}: ${result.stderr}`) + } finally { + cleanup() + } + } +}) + +// A collection root plus a fake npm whose `view ` prints +// npmVersions[spec], or fails with E404 when the spec is missing or null. +function dependencyFixture({ npmVersions, viewFails = false }) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gea-publish-deps-')) + const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'gea-publish-npm-')) + const npmLog = path.join(root, 'npm.log') + const versionsFile = path.join(bin, 'versions.json') + fs.writeFileSync(versionsFile, JSON.stringify(npmVersions)) + fs.writeFileSync(npmLog, '') + writeFakeNpm(bin, { + bash: `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$NPM_LOG" +case "$1" in + --version) printf '10.0.0\\n' ;; + whoami) printf 'geastack\\n' ;; + view) + ${viewFails ? "printf 'npm error code ETIMEDOUT\\n' >&2; exit 1" : `exec "${process.execPath}" -e ' + const versions = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")) + const value = versions[process.argv[2]] + if (value == null) { console.error("npm error code E404"); process.exit(1) } + console.log(value.startsWith("[") ? value : JSON.stringify(value)) + ' "${versionsFile}" "$2"`} + ;; + dist-tag) printf 'tagged\\n' ;; + publish) printf 'published\\n' ;; +esac +`, + replies: { '--version': '10.0.0', whoami: 'geastack', 'dist-tag': 'tagged', publish: 'published' }, + extra: `if (args[0] === 'view') { + ${viewFails ? "console.error('npm error code ETIMEDOUT')\n process.exit(1)" : `const versions = JSON.parse(fs.readFileSync(${JSON.stringify(versionsFile)}, 'utf8')) + const value = versions[args[1]] + if (value == null) { + console.error('npm error code E404') + process.exit(1) + } + console.log(value.startsWith('[') ? value : JSON.stringify(value))`} +} +` + }) + return { + root, + npmCommands: () => fs.readFileSync(npmLog, 'utf8').split('\n').filter((line) => line.startsWith('view') || line.startsWith('publish')).join('\n'), + run: (args) => spawnSync(process.execPath, [scriptPath, '--root', root, ...args], { + encoding: 'utf8', + env: withPathPrefix({ ...process.env, NPM_LOG: npmLog }, bin) + }), + cleanup: () => { + fs.rmSync(root, { recursive: true, force: true }) + fs.rmSync(bin, { recursive: true, force: true }) + } + } +} + // The npm on PATH, faked: it logs its arguments to $NPM_LOG and answers by // its first argument. POSIX hosts run the bash script; on Windows, where npm // is an npm.cmd shim, the same behaviour runs behind one (`replies` print a // line and succeed, `refuses` print to stderr and exit 42). -function writeFakeNpm(bin, { bash, replies, refuses = {} }) { +function writeFakeNpm(bin, { bash, replies, refuses = {}, extra = '' }) { writeFakeCommand(bin, 'npm', { bash, node: `const fs = require('node:fs') @@ -147,17 +372,18 @@ if (Object.hasOwn(refuses, args[0])) { console.error(refuses[args[0]]) process.exit(42) } -`, +${extra}`, windowsLauncher: 'cmd' }) } -function writePublishable(root, relativePath, name) { +function writePublishable(root, relativePath, name, fields = {}) { writeJson(path.join(root, relativePath), { name, version: '0.1.0', publishConfig: { access: 'restricted' - } + }, + ...fields }) } From 565aa5c17bab34ff7f1801ac90addfe9c522db91 Mon Sep 17 00:00:00 2001 From: Ozgur Ersoy Date: Fri, 16 Oct 2026 19:21:07 +0200 Subject: [PATCH 2/2] Publish the debugger repo with the rest of the @geastack packages The CLI depends on @geastack/debugger, but the publish script did not look in the debugger repo, so the debugger was released by hand and could fall behind the range the CLI needs. It is now in the default repo list, ahead of cli, and the publish order puts it before the CLI. --- scripts/publish-private-packages.mjs | 5 +++-- test/publish-private-packages.test.mjs | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/publish-private-packages.mjs b/scripts/publish-private-packages.mjs index 688c364..0c695a6 100755 --- a/scripts/publish-private-packages.mjs +++ b/scripts/publish-private-packages.mjs @@ -12,8 +12,9 @@ const DEFAULT_REGISTRY = 'https://registry.npmjs.org/' // Every repository that publishes an @geastack package. A repo missing here is // a release pushed by hand that can drift from the versions the others expect: // `targets` was missing once, then `node-compat` (a hard dependency of the -// compiler), `native-webgl-angle` and `windows`. -const DEFAULT_REPOS = ['core', 'compiler', 'node-compat', 'apple', 'windows', 'native-webgl-angle', 'simulator', 'targets', 'cli'] +// compiler), `native-webgl-angle`, `windows` and `debugger` (a hard dependency of +// the CLI). +const DEFAULT_REPOS = ['core', 'compiler', 'node-compat', 'apple', 'windows', 'native-webgl-angle', 'simulator', 'targets', 'debugger', 'cli'] // Build output and scratch trees carry copies of package.json files that would // shadow the real packages: `generated` (the simulator's staged framework // sources), `.work` (benchmark checkouts), `.state` (sweep source contexts), diff --git a/test/publish-private-packages.test.mjs b/test/publish-private-packages.test.mjs index 1144b57..f938a7f 100644 --- a/test/publish-private-packages.test.mjs +++ b/test/publish-private-packages.test.mjs @@ -31,6 +31,23 @@ test('publish plan ignores transient .state package copies', () => { } }) +test('publish plan includes the debugger repo ahead of the CLI that depends on it', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gea-publish-debugger-')) + try { + writePublishable(root, 'debugger/package.json', '@geastack/debugger') + writePublishable(root, 'cli/package.json', '@geastack/cli', { + dependencies: { '@geastack/debugger': '^0.1.0' } + }) + + const result = spawnSync(process.execPath, [scriptPath, '--root', root, '--plan'], { encoding: 'utf8' }) + + assert.equal(result.status, 0, result.stderr) + assert.match(result.stdout, /@geastack\/debugger@0\.1\.0[\s\S]*@geastack\/cli@0\.1\.0/) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + test('real publish mode tags existing package versions instead of skipping them', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gea-publish-existing-')) const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'gea-publish-npm-'))