etherpad-lite/snap
John McLear 04045fe532
Roll Node.js floor back to >= 24 (Active LTS) — closes #7779 (#7781)
* Roll Node.js floor back to >= 24 (Active LTS)

Closes #7779.

#7779 originally proposed bumping past the Node 25 stop-gap to Node 26.
After re-checking the release schedule, the cleaner LTS target is
actually Node 24:

- Node 24 (Krypton) is currently in Active LTS, supported until ~May 2028.
- Node 25 hit end-of-life on April 10 2026 — the floor merged in
  #7752 / #7749 / #7754 a day ago ships an already-EOL major.
- Node 26 was released May 5 2026 and does not enter Active LTS until
  October 2026.

So this PR reverts the Node 25 ratchet from those three PRs and lands
on Node 24 — Etherpad's runtime floor stays on a supported LTS for the
next ~2 years.

Runtime / infra
- `package.json` + `src/package.json`: `engines.node` `>=25.0.0` -> `>=24.0.0`
- `bin/functions.sh`, `bin/installer.sh`, `bin/installer.ps1`:
  `REQUIRED_NODE_MAJOR` 25 -> 24
- `Dockerfile`: `node:25-alpine` -> `node:24-alpine` (both stages).
  Corepack-via-npm workaround is intentionally kept: it works on
  Node 24 (which still ships corepack) and on Node 25+ (which doesn't),
  so the same recipe survives the next LTS bump without churn. Comments
  reworded accordingly.
- `snap/snapcraft.yaml`: pinned `NODE_VERSION` 25.9.0 -> 24.15.0; design
  notes + corepack comment adjusted
- `packaging/nfpm.yaml`: `nodejs (>= 25)` -> `nodejs (>= 24)` in
  top-level depends + deb/rpm overrides
- `packaging/bin/etherpad`: comment matches the new pin
- `packaging/README.md`: build prereqs + apt install snippet point at
  `node_24.x`; the long-stale "engines.node floor is 20" line is fixed
  while we're here
- `.github/workflows/*.yml`: setup-node `node-version` 25 -> 24 across
  every workflow; backend / frontend-admin / upgrade matrices
  `[25]` -> `[24]`
- `.github/workflows/deb-package.yml`: `NODE_MAJOR=25` + `node_25.x`
  smoke-test installer -> 24
- `bin/plugins/lib/npmpublish.yml`: 25 -> 24 (template propagates to
  the ~80 ether/* plugins via update-plugins workflow)

Docs
- `README.md`: install one-liner + Requirements -> Node.js >= 24
- `doc/npm-trusted-publishing.md`: runner requirement -> Node 24
- `doc/plugins.md` / `doc/plugins.adoc`: plugin metadata example
  `engines.node` -> `">=24.0.0"`

@types/node is left at ^25.8.0 — newer type definitions cover Node 24
runtime fine and avoid an unnecessary lockfile churn.

Companion homepage one-liner change to follow on ether/ether.github.com.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(plugins): example engines.node = ">=22.0.0", not core's floor

Plugin code is overwhelmingly ace-hook glue and rarely uses Node-version-
specific APIs, so plugin engines.node should reflect the plugin's own
requirements, not track core. Showing core's 24-floor in the example
encouraged plugin authors to blindly copy a tighter pin than necessary
and locked plugins out of being installable on older Etherpad/Node
deployments. Use the most-recent Node LTS that has actually reached EOL
(20 -> EOL April 2026) as the example floor, i.e. >=22.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:07:59 +01:00
..
hooks
local/bin
tests
README.md
snapcraft.yaml

Etherpad snap

Packages Etherpad as a Snap for publishing to the Snap Store.

User-facing usage

Install from the store

sudo snap install etherpad

The default listen port is 9001. Pad data lives in /var/snap/etherpad/common/ and survives snap refresh.

Configure

The snap seeds $SNAP_COMMON/etc/settings.json from the upstream template on first run. Edit that file directly to customise Etherpad, then:

sudo snap restart etherpad

A few values are exposed as snap config so users don't have to edit the file by hand:

Key Default Notes
snap set etherpad port=9001 9001 Listen port
snap set etherpad ip=0.0.0.0 0.0.0.0 Bind address

The configure hook validates these (port must be 165535 integer, ip must be a valid v4/v6 address) and restarts the daemon on change.

Build locally

sudo snap install --classic snapcraft
sudo snap install lxd && sudo lxd init --auto
snapcraft            # from repo root; uses LXD by default

Output: etherpad_<version>_<arch>.snap.

Install a local build

sudo snap install --dangerous ./etherpad_*.snap
sudo snap start etherpad
curl http://127.0.0.1:9001/health   # → {"status":"pass","releaseId":"X.Y.Z"}

Logs: sudo snap logs etherpad -f.

Architecture

File layout inside the snap

$SNAP/                                # = /snap/etherpad/current  (read-only squashfs)
├── opt/
│   ├── node/bin/node                 # pinned Node.js 22.12.0
│   └── etherpad/
│       ├── src/                      # ep_etherpad-lite workspace package (with node_modules incl. tsx)
│       ├── admin/, ui/, doc/         # other workspace packages (built artefacts)
│       ├── settings.json.template    # template, copied to $SNAP_COMMON on first run
│       └── var → /var/snap/etherpad/common/etherpad-app-var/   # symlink (see below)
├── bin/
│   ├── etherpad-service              # daemon launch wrapper
│   ├── etherpad-cli                  # passthrough to bin/ scripts
│   └── etherpad-healthcheck-wrapper  # HTTP /health probe
└── meta/snap.yaml

$SNAP_COMMON/                         # = /var/snap/etherpad/common  (read-write, persists across refreshes)
├── etc/settings.json                 # seeded from template on first run, never overwritten
├── var/etherpad.db                   # sqlite database
├── etherpad-app-var/installed_plugins.json   # plugin registry, written by Etherpad core
└── logs/                             # reserved for future use

Etherpad's plugin installer (src/static/js/pluginfw/installer.ts) writes installed_plugins.json via __dirname-relative paths, which resolve to absolute paths inside $SNAP — read-only squashfs. Snap layouts can't intercept paths inside $SNAP, so we replace the shipped var/ directory with a symlink at build time pointing to /var/snap/etherpad/common/etherpad-app-var/ (created by the wrapper on first run). The kernel transparently follows the symlink to writable storage that survives snap refresh.

Why the seeded settings.json is rewritten

The upstream settings.json.template defaults to dbType: "dirty" — the template itself warns this is dev-only. The launch wrapper rewrites the seeded copy on first run to:

  • dbType: "sqlite" with file at $SNAP_COMMON/var/etherpad.db
  • ip: "${IP:0.0.0.0}" — Etherpad's own env-substitution syntax
  • port: "${PORT:9001}" — same

The wrapper then exports IP and PORT from the snap config (via snapctl get), so snap set etherpad port=N actually moves the listener.

Why pnpm runs twice

pnpm install --frozen-lockfile --prod=false first (need devDeps to build admin/ui/docs), then rm -rf node_modules && pnpm install --prod --frozen-lockfile --ignore-scripts after the build. This is faster than pnpm prune --prod, which is interactive on workspace projects (prompts "Proceed? (Y/n)" to stdin) and deadlocks under the non-interactive build environment. See nodejs/corepack#612 for the corepack-keyring refresh in step 2.

Why the daemon shares the snap name

apps.etherpad matches the snap name etherpad, so users invoke the daemon via snap install etherpad → bare etherpad command. The CLI passthrough is exposed as etherpad.cli (e.g. etherpad.cli importSqlFile something.sql).

Testing

Three layers, each independently runnable:

1. Wrapper unit tests (~5 s, no snapd/sudo)

bash snap/tests/run-all.sh

Runs bash -n syntax checks on every wrapper + hook, then sources each test-*.sh and reports pass/fail counts. Coverage:

  • test-snapcraft-yaml.sh — required keys, name validity, daemon-app matches snap name, no etherpad-lite regression, environment vars whitelist.
  • test-cli.sh — path-traversal rejection (../, subdir, empty), .ts / .sh dispatch, default-case rejection, no-args usage.
  • test-configure.sh — port (165535 integer) and ip (v4/v6) validation via mocked snapctl.
  • test-service-bootstrap.sh — first-run seeding from settings.json.template, sed rewrite of dbType/filename/ip/port, writable-dir creation, snapctl override propagation to node env, idempotency on second run, default fallbacks.

All tests use port 9003 for any binding (per project convention, since 9001 is reserved for ad-hoc local Etherpad work).

2. CI build verification

.github/workflows/snap-build.yml runs on every PR that touches snap/, settings.json.template, or the workflow itself. Two jobs:

  • wrapper-tests — runs snap/tests/run-all.sh (~5 s).
  • snap-pack — runs snapcraft pack --destructive-mode and uploads the resulting .snap as an artifact (downloadable from the run summary so reviewers can sideload).

This is intentionally separate from snap-publish.yml (tag-triggered, LXD-based, pushes to the store).

3. End-to-end smoke test (~3 min, requires sudo + snapd)

bash snap/tests/smoke.sh

Rebuilds via destructive-mode, installs the resulting .snap, configures port=9003, restarts, waits for plugin migration to finish, asserts a listener on 9003, hits /health, and tails the last 20 log lines. Useful when changing the wrappers or the build recipe before pushing.

Development workflow

# 1. Make a change to snap/snapcraft.yaml or one of the wrappers.

# 2. Fast feedback loop — only the unit tests:
bash snap/tests/run-all.sh

# 3. Full local verification — actually build and install:
bash snap/tests/smoke.sh

# 4. Push. CI will run wrapper-tests + snap-pack on the PR.
git push

If snapcraft pack complains about the LXD provider, --destructive-mode lets you build directly on the host (used by both the smoke script and CI). It pollutes the host with build deps and puts parts/, stage/, prime/ in the worktree (gitignored). Wipe with sudo rm -rf parts stage prime.

Publishing

Maintainers only. See:

One-time setup:

snapcraft register etherpad
snapcraft export-login --snaps etherpad \
  --channels edge,stable \
  --acls package_access,package_push,package_release -

Store the printed credential in the repo secret SNAPCRAFT_STORE_CREDENTIALS. Create a GitHub Environment named snap-store-stable with required reviewers so stable promotion is gated.

.github/workflows/snap-publish.yml then handles the rest on every vX.Y.Z (or X.Y.Z) tag: build → publish to edge → manual approval gate → publish to stable.

Troubleshooting

Daemon flapping with EROFS: read-only file system — Etherpad is trying to write somewhere inside $SNAP. Check whether the path is covered by the var/ symlink (architecture section above). New write targets need either an additional symlink at build time (snap/snapcraft.yaml step 4) or a config knob to redirect into $SNAP_COMMON.

Cannot find package 'tsx' — the wrapper must cd "${APP_DIR}/src" before node, since tsx lives in the workspace's node_modules and not at the install root under pnpm hoisting.

ERR_REQUIRE_CYCLE_MODULE — use bare --import tsx, not --import tsx/esm. The ESM-only loader trips on Etherpad's mixed CJS/ESM source.

snap install fails with unable to contact snap store — almost always a Canonical-side outage. Check snapcraft.statuspage.io. For local development you can sidestep the store dependency entirely by building with snapcraft pack --destructive-mode (no LXD container provisioning, so no in-container snap install).

pnpm prune --prod hangs forever — never use it directly here. It has an interactive "Proceed? (Y/n)" prompt for workspaces that deadlocks under sudo/tee. The build recipe uses rm -rf node_modules && pnpm install --prod --frozen-lockfile --ignore-scripts instead.

snap refresh blew away my data — it didn't. Pad data is in /var/snap/etherpad/common/, which is preserved across refreshes. Check /var/snap/etherpad/common/var/etherpad.db exists.