From 69ddd2910c9827b8a59172d4587b1625bbe88465 Mon Sep 17 00:00:00 2001 From: Vihaan varnika Date: Sat, 4 Jul 2026 11:04:37 +0530 Subject: [PATCH 1/3] fix(arborist): honor min-release-age-exclude when reify re-resolves a node missing resolved --- workspaces/arborist/lib/arborist/reify.js | 13 ++++++ workspaces/arborist/test/arborist/reify.js | 50 ++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index b099d4d72c6a4..037488509c28d 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -32,6 +32,7 @@ const Shrinkwrap = require('../shrinkwrap.js') const { defaultLockfileVersion } = Shrinkwrap const { saveTypeMap, hasSubKey } = require('../add-rm-pkg-deps.js') const { IsolatedNode, IsolatedLink } = require('../isolated-classes.js') +const { isReleaseAgeExcluded } = require('../release-age-exclude.js') // Part of steps (steps need refactoring before we can do anything about these) const _retireShallowNodes = Symbol.for('retireShallowNodes') @@ -737,8 +738,20 @@ module.exports = cls => class Reifier extends cls { }) } }) + // When node.resolved is missing (e.g. a lockfile written with + // omit-lockfile-registry-resolved) `res` above is a bare `name@version` + // spec, so pacote has to hit the registry and re-run version-picking + // (including the `min-release-age`/`before` cutoff) to find the tarball. + // That re-resolution must honor `min-release-age-exclude` the same way + // build-ideal-tree's #releaseAgeBefore does, or a package that was + // correctly exempted while building the ideal tree can still get + // rejected here with ETARGET. + const before = isReleaseAgeExcluded(node.name, this.options.minReleaseAgeExclude) + ? null + : this.options.before await pacote.extract(res, node.path, { ...this.options, + before, resolved: node.resolved, integrity: node.integrity, // A node counts as "root" for allow-* enforcement if it satisfies at least one valid dependency edge declared by the project root or a workspace. diff --git a/workspaces/arborist/test/arborist/reify.js b/workspaces/arborist/test/arborist/reify.js index d453c79baa325..f3b74981f333e 100644 --- a/workspaces/arborist/test/arborist/reify.js +++ b/workspaces/arborist/test/arborist/reify.js @@ -167,6 +167,56 @@ t.test('weirdly broken lockfile without resolved value', async t => { await t.resolveMatchSnapshot(printReified(fixture(t, 'dep-missing-resolved'))) }) +t.test('min-release-age-exclude applies when reify re-resolves a node with no resolved value', async t => { + // minimist@1.2.5 was published 2020-03-12T22:16:19.463Z (see + // ../fixtures/registry-mocks/content/minimist.json). A lockfile entry + // missing `resolved` (e.g. written with omit-lockfile-registry-resolved) + // forces reify's #extractOrLink to re-resolve `minimist@1.2.5` against the + // registry instead of extracting directly from a known tarball URL. That + // re-resolution must still honor min-release-age-exclude the same way + // build-ideal-tree's manifest fetches do. + const before = new Date('2020-01-01T00:00:00.000Z') + + const mkPath = t => t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { minimist: '1.2.5' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 2, + requires: true, + packages: { + '': { + name: 'root', + version: '1.0.0', + dependencies: { minimist: '1.2.5' }, + }, + 'node_modules/minimist': { + version: '1.2.5', + }, + }, + }, null, 2), + }) + + await t.test('without exclude, before still applies and fails', async t => { + createRegistry(t, true) + await t.rejects( + reify(mkPath(t), { before }), + { code: 'ETARGET' }, + 'the already-locked version is rejected by the release-age filter' + ) + }) + + await t.test('with exclude, the already-locked version installs', async t => { + createRegistry(t, true) + const tree = await reify(mkPath(t), { before, minReleaseAgeExclude: ['minimist'] }) + t.equal(tree.children.get('minimist').version, '1.2.5', 'excluded package still installs') + }) +}) + t.test('testing-peer-deps package', async t => { createRegistry(t, true) await t.resolveMatchSnapshot(printReified(fixture(t, 'testing-peer-deps'))) From 0dd6dcf683859e0cdfbd0c1215932b5811be556a Mon Sep 17 00:00:00 2001 From: Vihaan varnika Date: Tue, 7 Jul 2026 11:53:25 +0530 Subject: [PATCH 2/3] fix(libnpmversion): write package.json atomically to avoid workspace race --- workspaces/libnpmversion/lib/write-json.js | 17 ++++- workspaces/libnpmversion/test/write-json.js | 69 ++++++++++++++++++++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/workspaces/libnpmversion/lib/write-json.js b/workspaces/libnpmversion/lib/write-json.js index 2f19953d75d28..f7510ec7964ea 100644 --- a/workspaces/libnpmversion/lib/write-json.js +++ b/workspaces/libnpmversion/lib/write-json.js @@ -1,5 +1,11 @@ // write the json back, preserving the line breaks and indent -const { writeFile } = require('node:fs/promises') +// +// writes are done atomically (write to a temp file, then rename over the +// target) so that a concurrent reader never observes a truncated or +// partially written package.json, e.g. when `npm version` is parallelized +// across workspaces. +const { writeFile, rename, unlink } = require('node:fs/promises') +const { randomBytes } = require('node:crypto') const kIndent = Symbol.for('indent') const kNewline = Symbol.for('newline') @@ -11,5 +17,12 @@ module.exports = async (path, pkg) => { delete pkg._id const raw = JSON.stringify(pkg, null, indent) + '\n' const data = newline === '\n' ? raw : raw.split('\n').join(newline) - return writeFile(path, data) + const tmp = `${path}.${randomBytes(6).toString('hex')}.tmp` + try { + await writeFile(tmp, data) + await rename(tmp, path) + } catch (err) { + await unlink(tmp).catch(() => {}) + throw err + } } diff --git a/workspaces/libnpmversion/test/write-json.js b/workspaces/libnpmversion/test/write-json.js index 98c67897c53c9..56ffc69e316b5 100644 --- a/workspaces/libnpmversion/test/write-json.js +++ b/workspaces/libnpmversion/test/write-json.js @@ -1,7 +1,7 @@ const t = require('tap') const path = require('node:path') const writeJson = require('../lib/write-json.js') -const { readFile } = require('node:fs/promises') +const { readFile, readdir, mkdir } = require('node:fs/promises') const kIndent = Symbol.for('indent') const kNewline = Symbol.for('newline') @@ -52,3 +52,70 @@ t.test('write json with newlines and indent set', async t => { t.end() }) + +t.test('writes atomically, never leaving a truncated file for readers', async t => { + const dir = t.testdir() + const file = path.join(dir, 'package.json') + + // seed an initial version of the file + await writeJson(file, { a: 1 }) + + const bigPkg = { a: 2, filler: 'x'.repeat(100000) } + + const state = { sawTruncated: false, readsDone: false } + + const readLoop = async () => { + while (!state.readsDone) { + let str + try { + str = await readFile(file, 'utf-8') + } catch { + // file briefly missing between unlink/rename is not expected here, + // but ignore transient ENOENT if it ever occurs + continue + } + if (str !== '{\n "a": 1\n}\n') { + try { + JSON.parse(str) + } catch { + state.sawTruncated = true + } + } + } + } + + const readers = Array.from({ length: 20 }, () => readLoop()) + + await writeJson(file, bigPkg) + state.readsDone = true + await Promise.all(readers) + + t.equal(state.sawTruncated, false, 'no concurrent reader ever saw a truncated file') + + const final = await readFile(file, 'utf-8') + t.match(JSON.parse(final), { a: 2 }) + + const leftoverTmp = (await readdir(dir)).filter(f => f.endsWith('.tmp')) + t.strictSame(leftoverTmp, [], 'no leftover temp files') +}) + +t.test('cleans up the temp file when the rename fails', async t => { + const dir = t.testdir() + // the target path is a directory, so renaming the temp file onto it fails + const target = path.join(dir, 'not-a-file') + await mkdir(target) + + await t.rejects(writeJson(target, { a: 1 })) + + const leftoverTmp = (await readdir(dir)).filter(f => f.endsWith('.tmp')) + t.strictSame(leftoverTmp, [], 'temp file was cleaned up after failure') +}) + +t.test('swallows the cleanup error when the temp file was never created', async t => { + const dir = t.testdir() + // the parent dir doesn't exist, so the initial write to the temp file + // fails before it's ever created, and the unlink cleanup also fails + const target = path.join(dir, 'missing-subdir', 'package.json') + + await t.rejects(writeJson(target, { a: 1 })) +}) From d56c6042d1cdc87309e82912127e847f6343db99 Mon Sep 17 00:00:00 2001 From: Vihaan varnika Date: Tue, 7 Jul 2026 12:09:17 +0530 Subject: [PATCH 3/3] Revert "fix(libnpmversion): write package.json atomically to avoid workspace race" This reverts commit 0dd6dcf683859e0cdfbd0c1215932b5811be556a. --- workspaces/libnpmversion/lib/write-json.js | 17 +---- workspaces/libnpmversion/test/write-json.js | 69 +-------------------- 2 files changed, 3 insertions(+), 83 deletions(-) diff --git a/workspaces/libnpmversion/lib/write-json.js b/workspaces/libnpmversion/lib/write-json.js index f7510ec7964ea..2f19953d75d28 100644 --- a/workspaces/libnpmversion/lib/write-json.js +++ b/workspaces/libnpmversion/lib/write-json.js @@ -1,11 +1,5 @@ // write the json back, preserving the line breaks and indent -// -// writes are done atomically (write to a temp file, then rename over the -// target) so that a concurrent reader never observes a truncated or -// partially written package.json, e.g. when `npm version` is parallelized -// across workspaces. -const { writeFile, rename, unlink } = require('node:fs/promises') -const { randomBytes } = require('node:crypto') +const { writeFile } = require('node:fs/promises') const kIndent = Symbol.for('indent') const kNewline = Symbol.for('newline') @@ -17,12 +11,5 @@ module.exports = async (path, pkg) => { delete pkg._id const raw = JSON.stringify(pkg, null, indent) + '\n' const data = newline === '\n' ? raw : raw.split('\n').join(newline) - const tmp = `${path}.${randomBytes(6).toString('hex')}.tmp` - try { - await writeFile(tmp, data) - await rename(tmp, path) - } catch (err) { - await unlink(tmp).catch(() => {}) - throw err - } + return writeFile(path, data) } diff --git a/workspaces/libnpmversion/test/write-json.js b/workspaces/libnpmversion/test/write-json.js index 56ffc69e316b5..98c67897c53c9 100644 --- a/workspaces/libnpmversion/test/write-json.js +++ b/workspaces/libnpmversion/test/write-json.js @@ -1,7 +1,7 @@ const t = require('tap') const path = require('node:path') const writeJson = require('../lib/write-json.js') -const { readFile, readdir, mkdir } = require('node:fs/promises') +const { readFile } = require('node:fs/promises') const kIndent = Symbol.for('indent') const kNewline = Symbol.for('newline') @@ -52,70 +52,3 @@ t.test('write json with newlines and indent set', async t => { t.end() }) - -t.test('writes atomically, never leaving a truncated file for readers', async t => { - const dir = t.testdir() - const file = path.join(dir, 'package.json') - - // seed an initial version of the file - await writeJson(file, { a: 1 }) - - const bigPkg = { a: 2, filler: 'x'.repeat(100000) } - - const state = { sawTruncated: false, readsDone: false } - - const readLoop = async () => { - while (!state.readsDone) { - let str - try { - str = await readFile(file, 'utf-8') - } catch { - // file briefly missing between unlink/rename is not expected here, - // but ignore transient ENOENT if it ever occurs - continue - } - if (str !== '{\n "a": 1\n}\n') { - try { - JSON.parse(str) - } catch { - state.sawTruncated = true - } - } - } - } - - const readers = Array.from({ length: 20 }, () => readLoop()) - - await writeJson(file, bigPkg) - state.readsDone = true - await Promise.all(readers) - - t.equal(state.sawTruncated, false, 'no concurrent reader ever saw a truncated file') - - const final = await readFile(file, 'utf-8') - t.match(JSON.parse(final), { a: 2 }) - - const leftoverTmp = (await readdir(dir)).filter(f => f.endsWith('.tmp')) - t.strictSame(leftoverTmp, [], 'no leftover temp files') -}) - -t.test('cleans up the temp file when the rename fails', async t => { - const dir = t.testdir() - // the target path is a directory, so renaming the temp file onto it fails - const target = path.join(dir, 'not-a-file') - await mkdir(target) - - await t.rejects(writeJson(target, { a: 1 })) - - const leftoverTmp = (await readdir(dir)).filter(f => f.endsWith('.tmp')) - t.strictSame(leftoverTmp, [], 'temp file was cleaned up after failure') -}) - -t.test('swallows the cleanup error when the temp file was never created', async t => { - const dir = t.testdir() - // the parent dir doesn't exist, so the initial write to the temp file - // fails before it's ever created, and the unlink cleanup also fails - const target = path.join(dir, 'missing-subdir', 'package.json') - - await t.rejects(writeJson(target, { a: 1 })) -})