feat(packaging): add Debian (.deb) build via nfpm with systemd unit#7559
feat(packaging): add Debian (.deb) build via nfpm with systemd unit#7559JohnMcLear merged 2 commits intoether:developfrom
Conversation
First-class Debian packaging for Etherpad, producing signed-ready etherpad-lite_<version>_<arch>.deb artefacts for amd64 and arm64 from a single nfpm manifest. Installing the package gives users: - /opt/etherpad-lite with a prebuilt, self-contained node_modules/ — no pnpm required at runtime, just `nodejs (>= 20)`. - etherpad system user/group, created via `adduser` in preinst. - /etc/etherpad-lite/settings.json seeded from the template on first install, preserved across upgrades, removed on `purge`. - /var/lib/etherpad-lite owned by etherpad:etherpad, with the default dirty-DB retargeted there so ProtectSystem=strict works. - /lib/systemd/system/etherpad-lite.service — hardened unit (NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, RestrictAddressFamilies) with Restart=on-failure. - /usr/bin/etherpad-lite CLI wrapper running `node --import tsx/esm`. CI (.github/workflows/deb-package.yml) triggers on v* tags, builds both arches via native runners (ubuntu-latest + ubuntu-24.04-arm), smoke-tests the amd64 package end-to-end (install → systemctl start → curl /health → purge → confirm user removed), and attaches the artefacts to the GitHub Release. Publishing to an APT repo (Cloudsmith, Launchpad PPA, self-hosted reprepro) is intentionally out of scope — needs a governance decision on who holds the signing key. Recipes are documented in packaging/README.md. Refs ether#7529 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review Summary by QodoAdd Debian (.deb) packaging via nfpm with systemd service
WalkthroughsDescription• Add first-class Debian (.deb) packaging via nfpm for amd64 and arm64 • Create systemd service with hardened security settings and auto-restart • Implement Debian lifecycle scripts (preinst/postinst/prerm/postrm) • Add CI workflow for automated package building and smoke testing Diagramflowchart LR
A["Source Code<br/>+ node_modules"] -->|Stage| B["staging/<br/>opt/etherpad-lite"]
B -->|nfpm package| C[".deb Artifact<br/>amd64/arm64"]
D["nfpm.yaml<br/>manifest"] -->|Configure| C
E["Lifecycle Scripts<br/>preinst/postinst/prerm/postrm"] -->|Package| C
F["systemd Unit<br/>+ Hardening"] -->|Include| C
C -->|CI Workflow| G["GitHub Release<br/>Assets"]
C -->|Smoke Test| H["Install → Start<br/>→ Health Check"]
File Changes1. packaging/nfpm.yaml
|
Code Review by Qodo
1.
|
…rms, 2-space indent Addresses Qodo review feedback on ether#7559: 1. Smoke test false-positive: the `for` loop polling /health never failed the job if the endpoint stayed down — `curl && break || sleep 2` keeps returning 0 from the trailing `sleep`, so `set -e` never trips. CI could attach a broken .deb to a release. Fix: track success explicitly and exit 1 (plus dump journald logs for diagnostics) when the service never becomes healthy. 2. /etc/default/etherpad-lite was world-readable (0644). systemd loads it via `EnvironmentFile=…`, and Etherpad supports ${ENV_VAR}-substitution for secrets (DB_PASSWORD etc.), so any local user could read anything admins drop there. Fix: install the conffile as root:etherpad 0640 — only root and the service user can read it. 3. Indentation: reflow maintainer scripts from 4-space to 2-space to match the repo style rule. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Qodo's |
|
Thoughts on dirtydb vs something like sqlite? |
…v2) (#7583) * feat(packaging): add Debian (.deb) build via nfpm with systemd unit First-class Debian packaging for Etherpad, producing etherpad_<version>_<arch>.deb artefacts for amd64 and arm64 from a single nfpm manifest. Installing the package gives users: - /opt/etherpad with a prebuilt, self-contained node_modules/ — no pnpm required at runtime, just `nodejs (>= 20)`. - etherpad system user/group, created via `adduser` in preinst. - /etc/etherpad/settings.json seeded from the template on first install, preserved across upgrades, removed on `purge`. Seed rewrites dbType from the template's dev-only `dirty` default to `sqlite`, pointed at /var/lib/etherpad/etherpad.db so fresh installs get an ACID-safe DB without manual config. sqlite is shipped by ueberdb2 (rusty-store-kv), so no additional apt deps are needed. - /var/lib/etherpad owned by etherpad:etherpad, writable under the hardened unit's ProtectSystem=strict. - /lib/systemd/system/etherpad.service — hardened unit (NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, RestrictAddressFamilies) with Restart=on-failure. - /usr/bin/etherpad CLI wrapper running `node --import tsx/esm`. CI (.github/workflows/deb-package.yml) triggers on v* tags, builds both arches via native runners (ubuntu-latest + ubuntu-24.04-arm), smoke-tests the amd64 package end-to-end (install → verify sqlite default → systemctl start → curl /health → purge → confirm user removed), and attaches the artefacts to the GitHub Release. Re-introduces the work from #7559 (reverted in #7582) with two corrections: 1. Package name and all installed paths use `etherpad`, not `etherpad-lite` — matches the repo rename. Kept replaces/conflicts on `etherpad-lite` so any dev builds of the reverted PR upgrade cleanly. 2. Default dbType is `sqlite`, not `dirty`. The template's own comment says dirty is for testing only; shipping it by default to everyone who runs `apt install etherpad` is the wrong tradeoff for a production package. Publishing to an APT repo (Cloudsmith, Launchpad PPA, self-hosted reprepro) is intentionally out of scope — needs a governance decision on who holds the signing key. Recipes are documented in packaging/README.md. Refs #7529, #7559, #7582 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(packaging): address PR review — startup crashes, supply chain, Node LTS Addresses Qodo and SamTV12345 review feedback on #7583: - postinstall: symlink /opt/etherpad/var → /var/lib/etherpad/var so ProtectSystem=strict doesn't block runtime writes (var/js, installed_plugins.json, etc.). Existing ReadWritePaths covers it. - postinstall: seed installed_plugins.json with ep_etherpad-lite so checkForMigration() does not spawn `pnpm ls` on first boot — pnpm is not a runtime dep, and the bundled node_modules already contains every shipped plugin. Prevents network plugin installs at first run. - postremove: clean up the new var symlink on remove. - workflow: verify nfpm .deb sha256 against upstream checksums.txt before sudo dpkg -i (defense in depth). - workflow: bump Node 22 → 24 (current LTS, per SamTV12345). The deb Depends stays at nodejs (>= 20) to match Etherpad's engines.node. - workflow: smoke-test now asserts the var symlink and seeded installed_plugins.json exist post-install. - workflow: publish stable etherpad-latest_{amd64,arm64}.deb aliases alongside the versioned files in the GitHub Release. - README: bump Node guidance to 24, document /releases/latest URL, link to engines.node floor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(packaging): tsx CJS hook, plugin paths writable, glob tag triggers Addresses second-round Qodo review on #7583: - bin/etherpad: switch from `--import tsx/.../esm` to `--require tsx/cjs`. server.ts uses `exports.start = ...` which throws under the ESM loader; the prod script in src/package.json uses tsx/cjs for the same reason. - postinstall: symlink /opt/etherpad/src/plugin_packages → /var/lib/etherpad/plugin_packages and chgrp /opt/etherpad/src/node_modules to etherpad with mode 2775. Otherwise admin-UI plugin install EACCESes — those are the dirs LinkInstaller writes to. - systemd unit: add /opt/etherpad/src/node_modules to ReadWritePaths so symlink creation by the etherpad user is allowed under ProtectSystem=strict. plugin_packages is already covered via the symlink into /var/lib/etherpad. - postremove: clean up the new plugin_packages symlink on remove. - workflow: tag filters were `v[0-9]+.[0-9]+.[0-9]+`, but Actions tag filters are globs, not regex. `[0-9]+` matches one character, so multi-digit tags like v2.10.0 would never trigger. Switch to `v*.*.*` / `v*.*.*-*`, matching handleRelease.yml. - workflow smoke test now asserts plugin_packages symlink target, ownership of plugin_packages and node_modules. - test-local.sh: new script that builds the .deb and runs the same smoke test in a throwaway systemd-enabled Docker container, so failures are caught before pushing. - README: document test-local.sh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(packaging): test-local.sh — fix cgroups v2, add --no-systemd mode - systemd-in-docker on cgroups v2 needs --cgroupns=host and a writable /sys/fs/cgroup mount; the previous :ro version booted to nothing. - New --no-systemd mode: drops the systemd container in favour of plain ubuntu:24.04 + manual launch under the etherpad user. Validates the postinstall, wrapper, plugin paths, and /health without depending on the host's systemd-in-docker setup. Use it when --privileged systemd containers don't boot on your kernel/docker combo. - On systemd container exit the script now dumps the last 50 log lines and points at --no-systemd as the fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(packaging): test-local.sh — reuse cached image in --no-systemd If ubuntu:24.04 isn't on disk and the registry is unreachable, fall back to whichever ubuntu/debian image is already cached (e.g. the jrei/systemd-ubuntu image we pulled for the systemd path). Avoids a registry round-trip on flaky networks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: handle spawn errors in run_cmd; deb-package install order + offline-safe test src/node/utils/run_cmd.ts: Without `proc.on('error', ...)` a spawn failure (e.g. ENOENT for a missing binary) is emitted as an unlistened 'error' event, which Node treats as an uncaught exception that bypasses the awaiting try/catch and kills the process. The .deb hits this on first boot because plugins.ts spawns `pnpm --version` for a startup log line and pnpm isn't a runtime dep — Etherpad logs "Starting" then immediately stops. Reject the promise on 'error' so the existing try/catch in the caller actually catches it. packaging/scripts/postinstall.sh: chown /var/lib/etherpad/plugin_packages AFTER `cp -a` from the staged tree — `cp -a` preserves source (root) ownership and was re-rooting the directory we'd just chowned to etherpad. Same ordering the var symlink block already used. packaging/test-local.sh: Run `CI=1 pnpm install --frozen-lockfile` before staging so the package is built from a fresh, lockfile-consistent tree (matches CI). Fixes spurious "Cannot find module 'X'" failures from stale local symlinks pointing at out-of-date pnpm store paths. End-to-end test now passes: postinstall asserts pass, /health returns 200, dpkg --purge cleans up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: gitignore packaging build artefacts; drop accidental commit Drop packaging/etc/settings.json.dist that snuck into the previous commit (generated at build time by test-local.sh / CI from settings.json.template). Add /staging/, /dist/, /packaging/etc/ to .gitignore so they don't recur. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(plugins): downgrade missing-pnpm log from ERROR to debug The startup IIFE that logs the pnpm version is informational only. pnpm is a dev-only dependency: admin-UI plugin install goes through live-plugin-manager directly, and plugin migration is short-circuited when var/installed_plugins.json is present (e.g. on packaged installs). A missing pnpm on PATH is therefore expected on hardened deployments and shouldn't surface as a red ERROR in journalctl. Detect ENOENT specifically and log at debug; treat other errors (permission denied, etc.) as warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(packaging): smoke deb on PRs + backend test for run_cmd spawn errors CI gap: deb-package.yml only fired on v* tag pushes, so a PR that broke the .deb wasn't caught until release time. Wire it to PRs and develop pushes via a paths filter covering packaging files and the runtime files Etherpad needs at first boot. The release job already gates on `if: startsWith(github.ref, 'refs/tags/v')` so PR runs won't try to publish. Test gap: the run_cmd.ts spawn-error fix (commit 5eee789) had no test, which is how the bug shipped originally — plugins.ts spawned `pnpm --version` at startup, the rejection was never caught, and the .deb crashed mid-boot. Add a backend spec that exercises: - ENOENT for a missing binary -> rejects (regression test) - successful command -> resolves stdout - non-zero exit -> rejects with code backend-tests.yml's recursive mocha glob picks up the new spec automatically; no workflow change needed there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(packaging-ci): use NodeSource LTS for the smoke test (was Ubuntu's node 18) ubuntu-latest's default apt nodejs is 18.19.1, but our package requires nodejs (>= 20). The smoke test was doing `apt-get install nodejs` followed by `dpkg -i ... || apt-get install -f`, which on a node-18 host fails the dep check, then `-f` "fixes" by REMOVING the etherpad package — and the next assertion (test -x /usr/bin/etherpad) crashes. Match what packaging/test-local.sh and the README recommend: install node from NodeSource (current LTS) before installing the .deb. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(packaging-ci): sudo-prefix smoke assertions that read /etc/etherpad postinstall sets /etc/etherpad to 0750 root:etherpad (DB creds live here) and /var/lib/etherpad similarly. The GH Actions runner user isn't in the etherpad group, so 'test -f /etc/etherpad/settings.json' hits EACCES. Add sudo to each check that crosses one of those dirs. (Wrapping the whole block in `sudo bash <<EOF` would have been cleaner but YAML literal-block + heredoc terminator don't play well together at this indent.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(packaging): close chown -R symlink-deref escalation; Pre-Depends adduser postinstall: Use `chown -hR` instead of `chown -R` on /var/lib/etherpad/var and /var/lib/etherpad/plugin_packages. Both directories are writable by the unprivileged etherpad service user, so a symlink planted there could redirect root's chown onto arbitrary system files (e.g. /etc/shadow) on the next `apt upgrade`. -hR makes chown act on the symlink itself rather than its target — standard mitigation for this TOCTOU-style local privilege escalation. nfpm: Move adduser from Depends to Pre-Depends. preinst creates the etherpad user before unpacking; with plain `dpkg -i` (no apt) the Depends list isn't installed beforehand, so a minimal system without adduser would fail preinst before unpack and apt-get -f couldn't recover. Pre-Depends guarantees adduser is configured first. Both flagged in Qodo's persistent review of 3daf300. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(packaging): predepends lives at top-level deb:, not under overrides nfpm's Overridables schema doesn't include predepends; it's a deb-only top-level field. Previous commit nested it under overrides.deb, which caused nfpm to reject the entire manifest with "field predepends not found in type nfpm.Overridables" and broke both arch builds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(packaging): four Qodo follow-ups (CI ordering, secure node install, disable on remove, writable settings) deb-package.yml: - Move 'Resolve version' (which calls `node -p`) to AFTER setup-node so it doesn't depend on the runner image preinstalling node. - Replace `curl ... | sudo bash` NodeSource installer with the explicit gpg-key + sources.list approach. Same outcome (NodeSource LTS apt repo), but no execution of network-fetched code as root. Reduces blast radius if NodeSource's setup endpoint is ever compromised — we only trust the signed apt repo metadata. postinstall.sh: - /etc/etherpad/settings.json now etherpad:etherpad mode 0660 (was root:etherpad 0640). The admin /admin/settings UI persists changes by writing back to settings.settingsFilename; with the previous perms the etherpad user could read but not write, so saving via the admin UI failed silently. Group-only access preserved (DB creds still unreadable by other users). postremove.sh: - On `dpkg --remove`, run `systemctl disable etherpad.service` before `daemon-reload` so the wants/ symlink doesn't dangle after dpkg deletes the unit file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(packaging): narrow workflow token scope; pin local nfpm to NFPM_VERSION deb-package.yml: Workflow-level permissions was `contents: write` so the build job got write access on every PR run, even though only the release job needs it (to attach release assets). Narrow the workflow default to `contents: read` and let the release job opt back in to write — it already declares its own job-level `contents: write` block, so this is just removing an over-broad default. test-local.sh: The script defined NFPM_VERSION but then unconditionally ran `goreleaser/nfpm:latest`, so local builds could diverge from CI's pinned v2.43.0. Use the variable in the docker tag (stripping the leading "v" to match the image's tag scheme). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
First-class Debian packaging for Etherpad. Produces
etherpad-lite_<version>_<arch>.debfor amd64 and arm64 from a singlenfpmmanifest. Installing the package gives users:/opt/etherpad-litewith a prebuilt, self-containednode_modules/— no pnpm required at runtime, onlynodejs (>= 20).etherpadsystem user/group, created viaadduserinpreinst./etc/etherpad-lite/settings.json(seeded from the template on first install; preserved across upgrades; removed onpurge)./var/lib/etherpad-liteowned byetherpad:etherpad, with the default dirty-DB path retargeted there soProtectSystem=strictworks./lib/systemd/system/etherpad-lite.service— hardened unit (NoNewPrivileges,ProtectSystem=strict,ProtectHome,PrivateTmp,RestrictAddressFamilies) withRestart=on-failure./usr/bin/etherpad-liteCLI wrapper runningnode --import tsx/esm.Part of #7529 — top-3 deployment targets (Snap #7558, Apt, Home Assistant).
CI
.github/workflows/deb-package.ymltriggers onv*tags, builds both arches via native runners (ubuntu-latest+ubuntu-24.04-arm), and smoke-tests the amd64 package end-to-end:dpkg -iinstallssystemctl start etherpad-litecurl /healthreturns 200dpkg --purgeremoves config + userArtefacts are attached to the GitHub Release.
Not included (follow-up)
Publishing to an APT repo (Cloudsmith / Launchpad PPA / self-hosted reprepro) is out of scope — needs a governance decision on who holds the signing key. Recipes are in
packaging/README.mdto be wired in once that's decided.Legacy
bin/buildDebian.shandbin/deb-src/are stale (Etherpad v1.3, init-based, unmaintained). Flagged for removal in a follow-up PR so this one stays mechanical.Test plan
pnpm install --frozen-lockfile && pnpm run build:etherpadsucceedsnfpm package --packager debproduces a well-formed.deb(dpkg-deb -I/-c)/healthreturns OK/etc/etherpad-lite/settings.jsonuntouched; service restartedapt removekeeps/etcand/var/lib;apt purgeremoves them plus theetherpaduserRefs #7529
🤖 Generated with Claude Code