Skip to content

Commit d589296

Browse files
JohnMcLearclaude
andcommitted
fix(docker): don't let pnpm self-provision a pinned version on offline boot (#7911)
The official Docker image installs pnpm directly via npm (corepack was dropped for Node 25+). Standalone pnpm still honours the "packageManager" pin in package.json: the image's pnpm intentionally lags that pin (pnpm 11.1.x enforces a minimum-release-age policy the frozen-lockfile build can't satisfy), so pnpm treats every invocation — including the informational `pnpm --version` probe Etherpad runs at startup — as a request to download and run the pinned build. Behind a corporate firewall / in an air-gapped install that download fails: [WARN] plugins - Failed to get pnpm version: Error: Command exited with code 1: pnpm --version which is what #7911 reported. Fix — neutralise the gap instead of closing it (closing it would break the frozen-lockfile build on 11.1.x): - Dockerfile build stage sets `pnpm_config_pm_on_fail=ignore` (the pnpm 11 successor to managePackageManagerVersions), inherited by the development and production runtime stages. pnpm then uses the installed pnpm instead of fetching the pinned one. It does not change which pnpm runs the build-time install, so the frozen-lockfile build is unaffected. - plugins.ts startup probe and the updater's pnpm-on-PATH checks run with the same flag, so the fix also covers non-Docker offline installs and the probe can never fail-loud. Add a backend spec that fails CI if the offline guard is dropped while the image pnpm differs from the package.json pin. Verified with a standalone (non-corepack) pnpm: a "packageManager" mismatch makes `pnpm --version` exit 1 by default (tries to fetch the pinned build), and exit 0 reading the local version with pm_on_fail=ignore. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d09227b commit d589296

6 files changed

Lines changed: 105 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ A defence-in-depth pass across the API, token, export, and deployment surfaces:
2727
- **URL-encode pad names in the admin 'Open' button and recent pads (#7865 / #7895).** Pad names are `encodeURIComponent`-d in the admin `PadPage` Open href and the colibris recent-pads href, and `decodeURIComponent`-d when read back from the URL pathname; legacy URL-encoded recent-pads names are normalised before re-encoding to prevent double-encoding (`%2F``%252F`). The admin Open `window.open` gains `noopener,noreferrer`.
2828
- **OIDC — fix broken `OIDCAdapter` flows (#7837).** Repairs the adapter flows and widens the storage type to include `string` for the `userCode` index; adds regression tests.
2929
- **Accessibility — dialog titles/descriptions and a missing l10n key (#7835 / #7836).** Adds the `index.code` key referenced by `index.html` but never defined (which produced a "Couldn't find translation key" console error on the landing page), and gives every admin `@radix-ui/react-dialog` `Dialog.Content` a `Dialog.Title` and `Dialog.Description` (visually hidden where there's no visible heading), silencing Radix's a11y warnings. A new backend spec fails CI if any `data-l10n-id` in `src/templates/*.html` is missing from `en.json`.
30+
- **Offline/air-gapped Docker boot — stop pnpm self-provisioning a pinned version (issue #7911).** The official image installs pnpm directly (corepack was dropped for Node 25+). Because the image's pnpm intentionally lags the `packageManager` pin in `package.json` (pnpm 11.1.x enforces a minimum-release-age policy the frozen-lockfile build can't satisfy), pnpm treated every call — including the informational `pnpm --version` probe Etherpad runs at startup — as a request to download the pinned build. Behind a firewall that download failed (`Failed to get pnpm version: … Command exited with code 1`), breaking startup. The Dockerfile now sets `pnpm_config_pm_on_fail=ignore`, and the startup probe plus the updater's pnpm-on-PATH checks run with the same flag, so pnpm uses the installed version instead of reaching for the network (without changing which pnpm runs the build-time install). A backend spec fails CI if that guard is dropped while a version gap exists.
3031
- **Dark mode — fix the white address bar and the light-flash on load (#7909, issue #7606).** Dark-mode users still saw a white mobile address bar above the dark toolbar, and the whole page flashed light before going dark. Both came from rendering the light state server-side and switching to dark only after the JS bundle ran: iOS Safari reads `theme-color` at parse time and doesn't reliably repaint on a later JS mutation, and the page painted light before the bundle applied the dark skin classes. The server now emits a `prefers-color-scheme`-scoped `theme-color` pair so the address bar is correct at first paint, plus a small blocking `<head>` script that applies the dark skin classes before the stylesheet paints. Both are gated on `enableDarkMode` (default on) and the colibris skin; `pad.ts` still runs on init to wire up the `#options-darkmode` toggle (which now updates every `theme-color` meta) and theme the editor iframes. Applies to the pad and timeslider views.
3132

3233
### Internal / contributor-facing

Dockerfile

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
# docker build --build-arg BUILD_ENV=copy .
88
ARG BUILD_ENV=git
99

10+
# NOTE: this intentionally lags the "packageManager" pin in package.json. pnpm
11+
# 11.1.x enforces the minimum-release-age supply-chain policy during install,
12+
# which the frozen-lockfile Docker build can't satisfy, so the image stays on
13+
# 11.0.x. The version gap is made harmless by pnpm_config_pm_on_fail=ignore in
14+
# the build stage below — see ether/etherpad#7911.
1015
ARG PnpmVersion=11.0.6
1116

1217
FROM node:24-alpine AS adminbuild
@@ -28,6 +33,17 @@ RUN pnpm run build:ui
2833
FROM node:24-alpine AS build
2934
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad"
3035

36+
# The image's pnpm intentionally lags the "packageManager" pin (see the ARG
37+
# note above). pnpm would otherwise try to self-provision the pinned version on
38+
# invocation — including the informational `pnpm --version` probe Etherpad runs
39+
# at startup — which fails closed with no network and breaks air-gapped boots
40+
# (ether/etherpad#7911). pm_on_fail=ignore makes pnpm use the installed version
41+
# instead. Inherited by the development and production runtime stages, so it
42+
# also covers the updater's pnpm-on-PATH check and ad-hoc `pnpm` in an exec
43+
# shell. It does not change which pnpm runs the build-time install (still the
44+
# installed 11.0.x), so the frozen-lockfile build is unaffected.
45+
ENV pnpm_config_pm_on_fail=ignore
46+
3147
# Set these arguments when building the image from behind a proxy
3248
ARG http_proxy=
3349
ARG https_proxy=

src/node/hooks/express/updateActions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,11 @@ const buildPreflightDeps = (installMethod: ReturnType<typeof getDetectedInstallM
8484
}
8585
},
8686
pnpmOnPath: () => new Promise<boolean>((resolve) => {
87-
const c = spawn('pnpm', ['--version'], {stdio: 'ignore'});
87+
// pm_on_fail=ignore so a "packageManager" pin mismatch doesn't make pnpm
88+
// exit non-zero (it would otherwise try to fetch the pinned build, which
89+
// fails offline) and falsely report pnpm as absent. See #7911.
90+
const c = spawn('pnpm', ['--version'],
91+
{stdio: 'ignore', env: {...process.env, pnpm_config_pm_on_fail: 'ignore'}});
8892
c.on('close', (code) => resolve(code === 0));
8993
c.on('error', () => resolve(false));
9094
}),

src/node/updater/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,11 @@ const buildSchedulerApplyDeps = (): ApplyPipelineDeps => ({
297297
}
298298
},
299299
pnpmOnPath: () => new Promise<boolean>((resolve) => {
300-
const c = spawn('pnpm', ['--version'], {stdio: 'ignore'});
300+
// pm_on_fail=ignore so a "packageManager" pin mismatch doesn't make pnpm
301+
// exit non-zero (it would otherwise try to fetch the pinned build, which
302+
// fails offline) and falsely report pnpm as absent. See #7911.
303+
const c = spawn('pnpm', ['--version'],
304+
{stdio: 'ignore', env: {...process.env, pnpm_config_pm_on_fail: 'ignore'}});
301305
c.on('close', (code) => resolve(code === 0));
302306
c.on('error', () => resolve(false));
303307
}),

src/static/js/pluginfw/plugins.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,16 @@ const logger = log4js.getLogger('plugins');
2020
// installs go through live-plugin-manager directly), so a missing pnpm
2121
// is expected on hardened/packaged installs and shouldn't produce an
2222
// ERROR in the logs.
23+
//
24+
// pnpm_config_pm_on_fail=ignore keeps this informational probe from failing
25+
// when the installed pnpm differs from package.json's "packageManager" pin:
26+
// by default pnpm would try to download the pinned version, which throws in
27+
// air-gapped/firewalled installs (ether/etherpad#7911). We only want to read
28+
// the local version here, never to self-provision a different one.
2329
(async () => {
2430
try {
25-
const version = await runCmd(['pnpm', '--version'], {stdio: [null, 'string']});
31+
const version = await runCmd(['pnpm', '--version'],
32+
{stdio: [null, 'string'], env: {...process.env, pnpm_config_pm_on_fail: 'ignore'}});
2633
logger.info(`pnpm --version: ${version}`);
2734
} catch (err) {
2835
if ((err as any).code === 'ENOENT') {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
'use strict';
2+
3+
// Regression tests for ether/etherpad#7911.
4+
//
5+
// The official Docker image installs pnpm directly (corepack was dropped for
6+
// Node 25+). Standalone pnpm still honours the "packageManager" pin in
7+
// package.json: when the pnpm baked into the image differs from that pin, pnpm
8+
// treats every invocation — including the informational `pnpm --version` probe
9+
// Etherpad runs at startup — as a request to self-provision the pinned build.
10+
// With no outbound network (air-gapped / behind a corporate firewall) that
11+
// download fails and pnpm exits non-zero, surfacing as `Failed to get pnpm
12+
// version` and breaking offline boots.
13+
//
14+
// The image deliberately lags the pin (pnpm 11.1.x enforces a minimum-release-
15+
// age policy the frozen-lockfile build can't satisfy), so the guard is not to
16+
// force the versions equal but to neutralise the gap: the Dockerfile must set
17+
// pnpm_config_pm_on_fail=ignore so pnpm uses the installed version instead of
18+
// reaching for the network. This test fails if that guard is dropped while a
19+
// version gap exists.
20+
21+
const assert = require('assert').strict;
22+
import fs from 'fs';
23+
import path from 'path';
24+
25+
const repoRoot = path.join(__dirname, '../../../../');
26+
const readRepoFile = (rel: string) => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
27+
28+
describe(__filename, function () {
29+
describe('Docker pnpm offline guard (issue #7911)', function () {
30+
let dockerfile: string;
31+
let imagePnpm: string;
32+
let pinPnpm: string;
33+
let guardPresent: boolean;
34+
35+
before(function () {
36+
dockerfile = readRepoFile('Dockerfile');
37+
38+
const argMatch = dockerfile.match(/^ARG PnpmVersion=(\S+)/m);
39+
assert.ok(argMatch, 'Dockerfile must declare `ARG PnpmVersion=<version>`');
40+
imagePnpm = argMatch![1];
41+
42+
const pkg = JSON.parse(readRepoFile('package.json'));
43+
const pinMatch = String(pkg.packageManager || '').match(/^pnpm@(.+)$/);
44+
assert.ok(pinMatch, `expected packageManager "pnpm@<version>", got "${pkg.packageManager}"`);
45+
pinPnpm = pinMatch![1];
46+
47+
guardPresent = /ENV\s+pnpm_config_pm_on_fail=ignore/.test(dockerfile);
48+
});
49+
50+
it('neutralises any pnpm version gap so offline boots do not self-provision', function () {
51+
// The actual safety property: a mismatch between the image pnpm and the
52+
// package.json pin is only safe when pm_on_fail=ignore is set. (If they
53+
// are ever realigned, the guard becomes belt-and-suspenders, not required.)
54+
if (imagePnpm !== pinPnpm) {
55+
assert.ok(guardPresent,
56+
`Dockerfile pnpm ${imagePnpm} differs from package.json pnpm@${pinPnpm}, ` +
57+
'but pnpm_config_pm_on_fail=ignore is not set — pnpm will try to ' +
58+
'self-provision the pinned version and break offline/air-gapped ' +
59+
'startup (issue #7911).');
60+
}
61+
});
62+
63+
it('sets pnpm_config_pm_on_fail=ignore for offline robustness', function () {
64+
assert.ok(guardPresent,
65+
'Dockerfile must set pnpm_config_pm_on_fail=ignore so runtime pnpm ' +
66+
'calls (the startup probe, the updater pnpm check) do not fail closed ' +
67+
'when offline (issue #7911).');
68+
});
69+
});
70+
});

0 commit comments

Comments
 (0)