Skip to content

Commit d19cc58

Browse files
committed
fix(docker): the runtime image installs its CLI and drivers under a pinned npm 11
node:22-slim bundles npm 10.9.9, whose arborist crashes with `edgesOut` on `npm install -g @objectstack/cli pg mysql2` since vitest@4.1.12 was published, and a global install reads no `overrides`. The install RUN now fetches npm@11.21.0 into /tmp with the image's npm, puts it first on PATH for that RUN only, asserts its version, and deletes it in the same layer, so the image keeps node:22-slim's npm and the installed set is unchanged. check-docs-image-tag anchors the driver install on `npm install -g` after a `RUN` or `&&` leader. The installer fetch (no `-g`) is not an anchor, and the self-test pins that the driver line is the one read. Claude-Session: https://claude.ai/code/session_016PVjjuVPte5XEJvaQx1E4t Co-authored-by: Claude <noreply@anthropic.com>
1 parent c913493 commit d19cc58

2 files changed

Lines changed: 120 additions & 9 deletions

File tree

‎docker/Dockerfile‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,34 @@ ARG OS_CLI_VERSION=latest
6666
# deployment can reach, so the two files must move in the same commit:
6767
# `pnpm check:docs-image-tag` fails when the install line and that table
6868
# disagree on the package set or on a version range.
69-
RUN npm install -g \
69+
#
70+
# The install runs under a PINNED npm 11, not the image's own npm (#23788).
71+
# node:22-slim bundles npm 10 (10.9.9, measured 2026-10-16), whose arborist
72+
# crashes on this install with `Cannot read properties of null (reading
73+
# 'edgesOut')` since vitest@4.1.12 was published: optional `vitest` peers in
74+
# the CLI's tree reach its self-contradicting peer set. npm 11 installs it,
75+
# and a global install reads no `overrides`, so the fix has to be the installer.
76+
# The version is the one `PROBE_INSTALLER_NPM` in
77+
# scripts/check-vendor-export-contract.mjs holds for the same crash.
78+
#
79+
# The installer is fetched into /tmp with the image's npm (npm bundles its
80+
# dependencies, so that fetch walks no peer graph), put first on PATH for this
81+
# RUN only, and deleted in the same layer: the image still ships node:22-slim's
82+
# npm, and does not grow by the installer's 20 MB. The `test` refuses to
83+
# continue if PATH did not hand the install to that exact npm. Not taken:
84+
# `npm install -g npm@…`, which replaces the image's npm for every user of the
85+
# image; `npx npm@…`, which leaves 20 MB in ~/.npm/_npx after `npm cache clean`
86+
# (measured); `--legacy-peer-deps`, which also skips REQUIRED peers (measured on
87+
# the vendor-export probe).
88+
RUN npm install --prefix /tmp/os-installer-npm --no-save --no-audit --no-fund "npm@11.21.0" \
89+
&& export PATH="/tmp/os-installer-npm/node_modules/.bin:$PATH" \
90+
&& test "$(npm --version)" = "11.21.0" \
91+
&& npm install -g \
7092
@objectstack/cli@${OS_CLI_VERSION} \
7193
"pg@^8.0.0" \
7294
"mysql2@^3.0.0" \
73-
&& npm cache clean --force
95+
&& npm cache clean --force \
96+
&& rm -rf /tmp/os-installer-npm
7497

7598
LABEL org.opencontainers.image.source="https://github.com/objectstack-ai/objectstack" \
7699
org.opencontainers.image.title="ObjectStack" \

‎scripts/check-docs-image-tag.mjs‎

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -414,8 +414,19 @@ export const DRIVER_PROMISE = {
414414
readme: 'docker/README.md',
415415
/** The README section whose first table lists what the image installs. */
416416
heading: '## Database drivers in the image',
417-
/** The Dockerfile command that installs them. Matched on a trimmed line. */
418-
installPrefix: 'RUN npm install -g',
417+
/**
418+
* The Dockerfile command that installs them. Matched at the start of a trimmed
419+
* line, after one of `installLeaders` and a space.
420+
*/
421+
installPrefix: 'npm install -g',
422+
/**
423+
* What may open the install line: `RUN` when the install starts its instruction,
424+
* `&&` when an earlier step of the SAME instruction runs first. The image fetches
425+
* a pinned npm in that earlier step (#23788). That fetch is an `npm install`
426+
* without `-g`, so it is not an anchor, and the gate reads the driver line rather
427+
* than the installer line.
428+
*/
429+
installLeaders: ['RUN', '&&'],
419430
/**
420431
* Installed packages that are NOT drivers.
421432
*
@@ -691,22 +702,38 @@ export function splitPackageSpec(spec) {
691702
return { name: spec.slice(0, at), range: spec.slice(at + 1) };
692703
}
693704

705+
/**
706+
* The leader that opens `line` as the driver install, or `null` when it is not
707+
* one: a trimmed line spelled `<leader> npm install -g` for a leader in
708+
* `DRIVER_PROMISE.installLeaders`.
709+
*
710+
* @param {string} line
711+
* @returns {string | null}
712+
*/
713+
export function installLeader(line) {
714+
const trimmed = line.trim();
715+
return DRIVER_PROMISE.installLeaders.find(
716+
(leader) => trimmed.startsWith(`${leader} ${DRIVER_PROMISE.installPrefix}`),
717+
) ?? null;
718+
}
719+
694720
/**
695721
* The drivers `docker/Dockerfile`'s global install puts into the image.
696722
*
697723
* Line continuations are joined before tokenising, because the install list is
698724
* one package per line -- reading only the anchor line would see the CLI and
699725
* none of the drivers, i.e. report an empty list over a populated one.
700726
*
701-
* The command is cut at the first `&&`: `npm cache clean --force` follows on the
702-
* same logical line and its tokens are not packages.
727+
* The command runs from its leader to the next `&&`: `npm cache clean --force`
728+
* follows on the same logical line and its tokens are not packages. A `&&` leader
729+
* is the separator BEFORE the install, so it is dropped before that cut.
703730
*
704731
* @param {string} text
705732
* @returns {{ found: boolean, line: number, drivers: { name: string, range: string, raw: string }[] }}
706733
*/
707734
export function extractInstalledDrivers(text) {
708735
const lines = text.split('\n');
709-
const start = lines.findIndex((line) => line.trim().startsWith(DRIVER_PROMISE.installPrefix));
736+
const start = lines.findIndex((line) => installLeader(line) !== null);
710737
if (start === -1) return { found: false, line: 0, drivers: [] };
711738

712739
let joined = '';
@@ -717,7 +744,7 @@ export function extractInstalledDrivers(text) {
717744
if (!continues) break;
718745
}
719746

720-
const command = joined.split('&&')[0];
747+
const command = joined.trim().slice(installLeader(lines[start]).length).split('&&')[0];
721748
const drivers = [];
722749
for (const token of command.trim().split(/\s+/)) {
723750
// Quotes are how a `^` range survives some shells; they are not part of the
@@ -813,7 +840,7 @@ export function checkDriverPromise({ root }) {
813840
file: DRIVER_PROMISE.dockerfile,
814841
line: 0,
815842
detail:
816-
`no line starts with '${DRIVER_PROMISE.installPrefix}', so this gate cannot see what the image `
843+
`no line starts with ${DRIVER_PROMISE.installLeaders.map((leader) => `'${leader} ${DRIVER_PROMISE.installPrefix}'`).join(' or ')}, so this gate cannot see what the image `
817844
+ 'installs and would report OK over a comparison it never made. Either the install was re-spelled '
818845
+ '-- re-anchor DRIVER_PROMISE.installPrefix -- or the image stopped installing drivers, in which '
819846
+ `case delete the '${DRIVER_PROMISE.heading}' section of ${DRIVER_PROMISE.readme} and this limb in `
@@ -2064,6 +2091,67 @@ async function selfTest() {
20642091
+ `this limb reports OK over a comparison it never made. Got ${JSON.stringify(driverNoInstall.findings.map((f) => f.kind))}`,
20652092
);
20662093

2094+
// #23788: the image fetches a pinned npm earlier in the SAME RUN, so the driver
2095+
// install opens with `&&`, and the line above it is an `npm install` too. The
2096+
// anchor must be the driver line, and the installer's spec is not a driver.
2097+
const PINNED_INSTALLER_INSTALL = [
2098+
'RUN npm install --prefix /tmp/os-installer-npm --no-save --no-audit --no-fund "npm@11.21.0" \\',
2099+
' && export PATH="/tmp/os-installer-npm/node_modules/.bin:$PATH" \\',
2100+
' && test "$(npm --version)" = "11.21.0" \\',
2101+
' && npm install -g \\',
2102+
' @objectstack/cli@${OS_CLI_VERSION} \\',
2103+
' "pg@^8.0.0" \\',
2104+
' "mysql2@^3.0.0" \\',
2105+
' && npm cache clean --force \\',
2106+
' && rm -rf /tmp/os-installer-npm',
2107+
];
2108+
const pinnedText = driverDockerfile(PINNED_INSTALLER_INSTALL);
2109+
const pinnedInstalled = extractInstalledDrivers(pinnedText);
2110+
const pinnedDriverLine = pinnedText.split('\n').findIndex((l) => l.trim() === '&& npm install -g \\') + 1;
2111+
const pinnedFetchLine = pinnedText.split('\n').findIndex((l) => l.includes('"npm@11.21.0"')) + 1;
2112+
assert(
2113+
pinnedInstalled.found && pinnedDriverLine > 0 && pinnedInstalled.line === pinnedDriverLine
2114+
&& pinnedInstalled.line !== pinnedFetchLine,
2115+
`with a pinned-npm fetch ahead of it, the anchor is the \`&& npm install -g\` driver line (${pinnedDriverLine}), `
2116+
+ `not the installer line (${pinnedFetchLine}) -- got line ${pinnedInstalled.line}`,
2117+
);
2118+
assert(
2119+
pinnedInstalled.drivers.map((d) => d.raw).join(',') === 'pg@^8.0.0,mysql2@^3.0.0',
2120+
'the driver line under a `&&` leader yields exactly the two driver specs: the leader is dropped before '
2121+
+ `the cut at the next \`&&\` -- got ${JSON.stringify(pinnedInstalled.drivers.map((d) => d.raw))}`,
2122+
);
2123+
assert(
2124+
!pinnedInstalled.drivers.some((d) => d.name === 'npm' || d.raw.includes('11.21.0')),
2125+
`the installer's \`npm@11.21.0\` is not a driver -- got ${JSON.stringify(pinnedInstalled.drivers.map((d) => d.raw))}`,
2126+
);
2127+
const driverPinned = driverFixture('drv-pinned-installer', PINNED_INSTALLER_INSTALL, CLEAN_ROWS);
2128+
assert(
2129+
driverPinned.findings.length === 0
2130+
&& driverPinned.stats.installed === 2 && driverPinned.stats.promised === 2 && driverPinned.stats.matched === 2,
2131+
'the pinned-installer Dockerfile agreeing with the table is green AND compared 2 against 2 -- '
2132+
+ `got ${JSON.stringify({ findings: driverPinned.findings.map((f) => f.kind), stats: driverPinned.stats })}`,
2133+
);
2134+
const driverPinnedDrift = driverFixture('drv-pinned-drift', PINNED_INSTALLER_INSTALL, [
2135+
'| `postgres://` | `pg@^7.0.0` |',
2136+
CLEAN_ROWS[1],
2137+
]);
2138+
assert(
2139+
driverPinnedDrift.findings.length === 1 && driverPinnedDrift.findings[0].kind === 'DRIVER-RANGE-MISMATCH',
2140+
'a drifted range is still refused when the driver line opens with `&&` -- '
2141+
+ `got ${JSON.stringify(driverPinnedDrift.findings.map((f) => f.kind))}`,
2142+
);
2143+
// An npm UPGRADE spelled as a global install ahead of the drivers is an anchor
2144+
// itself. It is read as the install line and refused, never skipped into a green.
2145+
const driverGlobalUpgrade = driverFixture('drv-global-npm-upgrade', [
2146+
'RUN npm install -g npm@11.21.0 \\',
2147+
...CLEAN_INSTALL.map((line) => line.replace(/^RUN /, ' && ')),
2148+
], CLEAN_ROWS);
2149+
assert(
2150+
driverGlobalUpgrade.findings.some((f) => f.kind === 'DRIVER-UNDOCUMENTED' && f.detail.includes('npm@11.21.0')),
2151+
'a global `npm install -g npm@…` ahead of the driver line is read as the install line and refused '
2152+
+ `(DRIVER-UNDOCUMENTED naming npm@11.21.0) -- got ${JSON.stringify(driverGlobalUpgrade.findings.map((f) => f.kind))}`,
2153+
);
2154+
20672155
const driverEmptyInstall = driverFixture('drv-emptyinstall', [
20682156
'RUN npm install -g @objectstack/cli@${OS_CLI_VERSION} \\',
20692157
' && npm cache clean --force',

0 commit comments

Comments
 (0)