diff --git a/.github/workflows/deb-package.yml b/.github/workflows/deb-package.yml new file mode 100644 index 00000000000..81f64f61eaf --- /dev/null +++ b/.github/workflows/deb-package.yml @@ -0,0 +1,161 @@ +name: Debian package +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-*' + workflow_dispatch: + inputs: + ref: + description: 'Git ref to package (defaults to current)' + required: false + +permissions: + contents: write # attach release assets + +env: + NFPM_VERSION: v2.43.0 + +jobs: + build: + name: Build .deb (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Resolve version + id: v + run: | + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + VERSION="${GITHUB_REF_NAME#v}" + else + VERSION="$(node -p "require('./package.json').version")" + fi + echo "version=${VERSION}" >>"$GITHUB_OUTPUT" + echo "Packaging version: ${VERSION}" + + - uses: pnpm/action-setup@v6 + with: + version: 10 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build UI + admin + run: pnpm run build:etherpad + + - name: Install nfpm + run: | + set -e + NFPM_ARCH=amd64 + [ "${{ matrix.arch }}" = "arm64" ] && NFPM_ARCH=arm64 + curl -fsSL -o /tmp/nfpm.deb \ + "https://github.com/goreleaser/nfpm/releases/download/${NFPM_VERSION}/nfpm_${NFPM_VERSION#v}_${NFPM_ARCH}.deb" + sudo dpkg -i /tmp/nfpm.deb + + - name: Stage tree for packaging + run: | + set -eux + STAGE=staging/opt/etherpad-lite + mkdir -p "${STAGE}" + + # Production footprint = src/ + bin/ + node_modules/ + metadata. + cp -a src bin package.json pnpm-workspace.yaml README.md LICENSE \ + node_modules "${STAGE}/" + + # Make pnpm-workspace.yaml production-only (same trick Dockerfile uses). + printf 'packages:\n - src\n - bin\n' > "${STAGE}/pnpm-workspace.yaml" + + mkdir -p packaging/etc + cp settings.json.template packaging/etc/settings.json.dist + + # Purge test fixtures and dev caches from node_modules to shrink size. + find "${STAGE}/node_modules" -type d \ + \( -name test -o -name tests -o -name '__tests__' \ + -o -name example -o -name examples -o -name docs \) \ + -prune -exec rm -rf {} + 2>/dev/null || true + find "${STAGE}/node_modules" -type f \ + \( -name '*.md' -o -name '*.ts.map' -o -name '*.map' \ + -o -name 'CHANGELOG*' -o -name 'HISTORY*' \) \ + -delete 2>/dev/null || true + + - name: Build .deb + env: + VERSION: ${{ steps.v.outputs.version }} + ARCH: ${{ matrix.arch }} + run: | + mkdir -p dist + nfpm package --packager deb -f packaging/nfpm.yaml --target dist/ + + - name: Smoke-test the package (amd64 only) + if: matrix.arch == 'amd64' + run: | + set -eux + sudo apt-get update + sudo apt-get install -y nodejs + sudo dpkg -i dist/*.deb || sudo apt-get install -f -y + test -x /usr/bin/etherpad-lite + test -f /etc/etherpad-lite/settings.json + test -L /opt/etherpad-lite/settings.json + id etherpad + systemctl cat etherpad-lite.service + sudo systemctl start etherpad-lite + ok= + for i in $(seq 1 30); do + if curl -fsS http://127.0.0.1:9001/health; then + ok=1 + break + fi + sleep 2 + done + if [ -z "${ok}" ]; then + # Attach logs so the failing run is diagnosable. + sudo journalctl -u etherpad-lite --no-pager -n 200 || true + exit 1 + fi + sudo systemctl stop etherpad-lite + sudo dpkg --purge etherpad-lite + ! id etherpad 2>/dev/null + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: etherpad-lite-${{ steps.v.outputs.version }}-${{ matrix.arch }}-deb + path: dist/*.deb + if-no-files-found: error + + release: + name: Attach to GitHub Release + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + pattern: etherpad-lite-*-deb + merge-multiple: true + - name: Attach .deb files to release + uses: softprops/action-gh-release@v2 + with: + files: dist/*.deb + fail_on_unmatched_files: true diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 00000000000..20b5b5f7beb --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,89 @@ +# Etherpad Debian / RPM packaging + +Produces native `.deb` (and, with the same manifest, `.rpm` / `.apk`) +packages for Etherpad using [nfpm](https://nfpm.goreleaser.com). + +## Layout + +``` +packaging/ + nfpm.yaml # nfpm package manifest + bin/etherpad-lite # /usr/bin launcher + scripts/ # preinst / postinst / prerm / postrm + systemd/etherpad-lite.service + systemd/etherpad-lite.default + etc/settings.json.dist # populated in CI from settings.json.template +``` + +Built artefacts land in `./dist/`. + +## Building locally + +Prereqs: Node 22, pnpm 10+, nfpm. + +```sh +pnpm install --frozen-lockfile +pnpm run build:etherpad + +# Stage the tree the way CI does: +STAGE=staging/opt/etherpad-lite +mkdir -p "$STAGE" +cp -a src bin package.json pnpm-workspace.yaml README.md LICENSE \ + node_modules "$STAGE/" +printf 'packages:\n - src\n - bin\n' > "$STAGE/pnpm-workspace.yaml" +cp settings.json.template packaging/etc/settings.json.dist + +VERSION=$(node -p "require('./package.json').version") \ +ARCH=amd64 \ + nfpm package --packager deb -f packaging/nfpm.yaml --target dist/ +``` + +## Installing + +```sh +sudo apt install ./dist/etherpad-lite_2.6.1_amd64.deb +sudo systemctl start etherpad-lite +curl http://localhost:9001/health +``` + +`apt` will pull in `nodejs (>= 20)`; on Ubuntu 22.04 add NodeSource first: + +```sh +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +``` + +## Configuration + +- Edit `/etc/etherpad-lite/settings.json`, then + `sudo systemctl restart etherpad-lite`. +- Environment overrides: `/etc/default/etherpad-lite`. +- Logs: `journalctl -u etherpad-lite -f`. +- Data (dirty-DB default): `/var/lib/etherpad-lite/`. + +## Upgrading + +`dpkg --install etherpad-lite_.deb` (or `apt install`) replaces the +app tree under `/opt/etherpad-lite` while preserving +`/etc/etherpad-lite/*` and `/var/lib/etherpad-lite/*`. The service is +restarted automatically. + +## Removing + +- `sudo apt remove etherpad-lite` — keeps config and data. +- `sudo apt purge etherpad-lite` — also removes config, data, and the + `etherpad` system user. + +## Publishing to an APT repository (follow-up) + +Out of scope here — requires credentials and ownership decisions. +Recipes once a repo is picked: + +- **Cloudsmith** (easiest, free OSS tier): + `cloudsmith push deb ether/etherpad-lite/any-distro/any-version dist/*.deb` +- **Launchpad PPA**: requires signed source packages (a `debian/` tree), + which nfpm does not produce — use `debuild` separately. +- **Self-hosted reprepro**: + `reprepro -b /srv/apt includedeb stable dist/*.deb` + +Wire the chosen option into `.github/workflows/deb-package.yml` after +the `release` job. diff --git a/packaging/bin/etherpad-lite b/packaging/bin/etherpad-lite new file mode 100755 index 00000000000..20143154ef8 --- /dev/null +++ b/packaging/bin/etherpad-lite @@ -0,0 +1,18 @@ +#!/bin/sh +# /usr/bin/etherpad-lite - thin wrapper that runs Etherpad in production mode. +# Invoked by the etherpad-lite.service systemd unit. +set -e + +APP_DIR="${ETHERPAD_DIR:-/opt/etherpad-lite}" +cd "${APP_DIR}" + +: "${NODE_ENV:=production}" +export NODE_ENV +export ETHERPAD_PRODUCTION=true + +# Run the server through tsx's ESM loader (shipped in node_modules). +# No pnpm needed at runtime. +exec node \ + --import "file://${APP_DIR}/src/node_modules/tsx/dist/esm/index.mjs" \ + "${APP_DIR}/src/node/server.ts" \ + "$@" diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 00000000000..eaf8ffce8c9 --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,117 @@ +# nfpm configuration for etherpad-lite Debian/RPM/APK packages. +# Build with: nfpm package --packager deb --target dist/ +# See: https://nfpm.goreleaser.com/configuration/ + +name: etherpad-lite +arch: ${ARCH} # amd64 | arm64 (exported by CI) +platform: linux +version: ${VERSION} # e.g. 2.6.1, stripped of leading "v" +version_schema: semver +release: "1" +section: web +priority: optional +maintainer: "Etherpad Foundation " +description: | + Etherpad is a real-time collaborative editor for the web. + This package installs Etherpad as a systemd service running + from /opt/etherpad-lite with configuration in /etc/etherpad-lite. +vendor: "Etherpad Foundation" +homepage: https://etherpad.org +license: Apache-2.0 + +depends: + - nodejs (>= 20) + - adduser + - ca-certificates + +recommends: + - libreoffice # enables DOC/DOCX/PDF/ODT export + - curl + +suggests: + - postgresql-client + - mariadb-client + +conflicts: + - etherpad # legacy bin/buildDebian.sh package name + +replaces: + - etherpad + +provides: + - etherpad + +# --------------------------------------------------------------------------- +# Contents. staging/ is populated by CI before invoking nfpm: +# staging/opt/etherpad-lite/ -- source + node_modules + built assets +# --------------------------------------------------------------------------- +contents: + - src: ./staging/opt/etherpad-lite + dst: /opt/etherpad-lite + type: tree + file_info: + mode: 0755 + owner: root + group: root + + - src: ./packaging/systemd/etherpad-lite.service + dst: /lib/systemd/system/etherpad-lite.service + file_info: + mode: 0644 + + # Default environment file (conffile: preserved on upgrade). + # Mode 0640 + group=etherpad so passwords/secrets admins drop in here + # are only readable by root and the etherpad service user — /etc/default + # is world-readable by default (0644), which would leak DB creds etc. + - src: ./packaging/systemd/etherpad-lite.default + dst: /etc/default/etherpad-lite + type: config|noreplace + file_info: + mode: 0640 + owner: root + group: etherpad + + - src: ./packaging/bin/etherpad-lite + dst: /usr/bin/etherpad-lite + file_info: + mode: 0755 + + # Template used by postinstall to seed /etc/etherpad-lite/settings.json. + # Intentionally NOT a conffile: postinstall creates the real settings.json + # once on first install and never touches it again, so upgrades don't + # prompt with dpkg merge dialogs. + - src: ./packaging/etc/settings.json.dist + dst: /usr/share/etherpad-lite/settings.json.dist + file_info: + mode: 0644 + + - dst: /etc/etherpad-lite + type: dir + file_info: + mode: 0755 + - dst: /var/lib/etherpad-lite + type: dir + file_info: + mode: 0750 + - dst: /var/log/etherpad-lite + type: dir + file_info: + mode: 0750 + +scripts: + preinstall: ./packaging/scripts/preinstall.sh + postinstall: ./packaging/scripts/postinstall.sh + preremove: ./packaging/scripts/preremove.sh + postremove: ./packaging/scripts/postremove.sh + +overrides: + deb: + depends: + - nodejs (>= 20) + - adduser + - ca-certificates + rpm: + depends: + - nodejs >= 20 + - shadow-utils + - ca-certificates diff --git a/packaging/scripts/postinstall.sh b/packaging/scripts/postinstall.sh new file mode 100755 index 00000000000..9ae2d122b2c --- /dev/null +++ b/packaging/scripts/postinstall.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# postinstall - runs after files have been unpacked. +# Debian actions: configure | abort-upgrade | abort-remove | abort-deconfigure +set -e + +ETC_DIR=/etc/etherpad-lite +VAR_DIR=/var/lib/etherpad-lite +LOG_DIR=/var/log/etherpad-lite +APP_DIR=/opt/etherpad-lite +DIST_SETTINGS=/usr/share/etherpad-lite/settings.json.dist +ACTIVE_SETTINGS="${ETC_DIR}/settings.json" + +case "$1" in + configure) + mkdir -p "${ETC_DIR}" "${VAR_DIR}" "${LOG_DIR}" + chown root:etherpad "${ETC_DIR}" + chmod 0750 "${ETC_DIR}" + chown etherpad:etherpad "${VAR_DIR}" "${LOG_DIR}" + chmod 0750 "${VAR_DIR}" "${LOG_DIR}" + + if [ ! -e "${ACTIVE_SETTINGS}" ]; then + cp "${DIST_SETTINGS}" "${ACTIVE_SETTINGS}" + # Point the default dirty-DB at /var/lib so ProtectSystem=strict works. + sed -i \ + 's|"filename": "var/dirty.db"|"filename": "/var/lib/etherpad-lite/dirty.db"|' \ + "${ACTIVE_SETTINGS}" + chown root:etherpad "${ACTIVE_SETTINGS}" + chmod 0640 "${ACTIVE_SETTINGS}" + fi + + # Etherpad reads settings.json from CWD (/opt/etherpad-lite). Expose + # the /etc copy there via symlink. + ln -sfn "${ACTIVE_SETTINGS}" "${APP_DIR}/settings.json" + + if [ -d "${APP_DIR}/var" ]; then + chown -R etherpad:etherpad "${APP_DIR}/var" || true + fi + + if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + # Enable on first install; leave state alone on upgrade. + if [ -z "$2" ]; then + systemctl enable etherpad-lite.service >/dev/null 2>&1 || true + fi + # Restart on upgrade to pick up new code (skip on fresh install -- + # admin may want to configure first). + if [ -n "$2" ]; then + systemctl try-restart etherpad-lite.service >/dev/null 2>&1 || true + fi + fi + + cat <&2 + exit 1 + ;; +esac + +exit 0 diff --git a/packaging/scripts/postremove.sh b/packaging/scripts/postremove.sh new file mode 100755 index 00000000000..b9ed40808d6 --- /dev/null +++ b/packaging/scripts/postremove.sh @@ -0,0 +1,43 @@ +#!/bin/sh +# postremove - runs after files are removed. +# Debian actions: remove | purge | upgrade | failed-upgrade | abort-install | +# abort-upgrade | disappear +set -e + +APP_DIR=/opt/etherpad-lite + +case "$1" in + remove) + [ -L "${APP_DIR}/settings.json" ] && rm -f "${APP_DIR}/settings.json" || true + if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + fi + ;; + + purge) + rm -rf /etc/etherpad-lite + rm -rf /var/lib/etherpad-lite + rm -rf /var/log/etherpad-lite + + if getent passwd etherpad >/dev/null 2>&1; then + deluser --system etherpad >/dev/null 2>&1 || true + fi + if getent group etherpad >/dev/null 2>&1; then + delgroup --system etherpad >/dev/null 2>&1 || true + fi + + if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + fi + ;; + + upgrade|failed-upgrade|abort-install|abort-upgrade|disappear) + ;; + + *) + echo "postremove called with unknown argument: $1" >&2 + exit 1 + ;; +esac + +exit 0 diff --git a/packaging/scripts/preinstall.sh b/packaging/scripts/preinstall.sh new file mode 100755 index 00000000000..d85c69054d9 --- /dev/null +++ b/packaging/scripts/preinstall.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# preinstall - runs before files are unpacked. +# Debian actions: install | upgrade | abort-upgrade +set -e + +case "$1" in + install|upgrade) + if ! getent group etherpad >/dev/null 2>&1; then + addgroup --system etherpad + fi + if ! getent passwd etherpad >/dev/null 2>&1; then + adduser --system --ingroup etherpad \ + --home /var/lib/etherpad-lite \ + --no-create-home \ + --shell /usr/sbin/nologin \ + --gecos "Etherpad service user" \ + etherpad + fi + ;; + abort-upgrade) + ;; + *) + echo "preinstall called with unknown argument: $1" >&2 + exit 1 + ;; +esac + +exit 0 diff --git a/packaging/scripts/preremove.sh b/packaging/scripts/preremove.sh new file mode 100755 index 00000000000..851fbb8b864 --- /dev/null +++ b/packaging/scripts/preremove.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# preremove - runs before files are removed. +# Debian actions: remove | upgrade | deconfigure | failed-upgrade +set -e + +case "$1" in + remove|upgrade|deconfigure) + if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then + systemctl stop etherpad-lite.service >/dev/null 2>&1 || true + fi + ;; + failed-upgrade) + ;; + *) + echo "preremove called with unknown argument: $1" >&2 + exit 1 + ;; +esac + +exit 0 diff --git a/packaging/systemd/etherpad-lite.default b/packaging/systemd/etherpad-lite.default new file mode 100644 index 00000000000..3ad92b0cd44 --- /dev/null +++ b/packaging/systemd/etherpad-lite.default @@ -0,0 +1,7 @@ +# /etc/default/etherpad-lite +# Environment overrides for the etherpad-lite systemd service. +# Any variable referenced by ${VAR:default} in settings.json can be set here. + +NODE_ENV=production +# PORT=9001 +# NODE_OPTIONS=--max-old-space-size=2048 diff --git a/packaging/systemd/etherpad-lite.service b/packaging/systemd/etherpad-lite.service new file mode 100644 index 00000000000..f13d7d6c6ca --- /dev/null +++ b/packaging/systemd/etherpad-lite.service @@ -0,0 +1,48 @@ +[Unit] +Description=Etherpad - real-time collaborative editor +Documentation=https://etherpad.org https://github.com/ether/etherpad-lite +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=etherpad +Group=etherpad +WorkingDirectory=/opt/etherpad-lite +EnvironmentFile=-/etc/default/etherpad-lite +ExecStart=/usr/bin/etherpad-lite +Restart=on-failure +RestartSec=5s +TimeoutStopSec=20s + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=etherpad-lite + +# --- Sandboxing --------------------------------------------------------- +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +ProtectHostname=true +ProtectClock=true +RestrictRealtime=true +RestrictSUIDSGID=true +RestrictNamespaces=true +LockPersonality=true +MemoryDenyWriteExecute=false # Node's JIT needs W+X mappings +SystemCallArchitectures=native +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +UMask=0027 + +ReadWritePaths=/var/lib/etherpad-lite /var/log/etherpad-lite /etc/etherpad-lite + +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target