name: Debian package on: push: tags: # Actions tag filters are globs, not regex. Mirrors handleRelease.yml. - 'v*.*.*' - 'v*.*.*-*' branches: [develop] paths: - 'packaging/**' - '.github/workflows/deb-package.yml' - 'src/package.json' - 'pnpm-lock.yaml' - 'src/node/server.ts' - 'src/node/utils/run_cmd.ts' - 'src/static/js/pluginfw/**' - 'settings.json.template' workflow_dispatch: inputs: ref: description: 'Git ref to package (defaults to current)' required: false # Default to read-only for the workflow; the release job opts in to # `contents: write` for itself only. Build jobs (which run on every PR) # don't need write and shouldn't have it. permissions: contents: read 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 }} - uses: pnpm/action-setup@v6 with: version: 10 - name: Setup Node uses: actions/setup-node@v6 with: node-version: '24' cache: pnpm - name: Resolve version id: v # Runs after setup-node so `node` is guaranteed available on # any runner image (some don't ship it preinstalled). 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}" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build UI + admin run: pnpm run build:etherpad - name: Install nfpm run: | set -euo pipefail NFPM_ARCH=amd64 [ "${{ matrix.arch }}" = "arm64" ] && NFPM_ARCH=arm64 NFPM_DEB="nfpm_${NFPM_VERSION#v}_${NFPM_ARCH}.deb" BASE="https://github.com/goreleaser/nfpm/releases/download/${NFPM_VERSION}" curl -fsSL -o "/tmp/${NFPM_DEB}" "${BASE}/${NFPM_DEB}" curl -fsSL -o /tmp/nfpm-checksums.txt "${BASE}/checksums.txt" # Verify upstream artifact before sudo dpkg -i (defense in depth # against a tampered release asset). ( cd /tmp && grep " ${NFPM_DEB}\$" nfpm-checksums.txt | sha256sum -c - ) sudo dpkg -i "/tmp/${NFPM_DEB}" - name: Stage tree for packaging run: | set -eux STAGE=staging/opt/etherpad 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 # Ubuntu's default apt nodejs is 18 — too old for our # `Depends: nodejs (>= 22)`. Add NodeSource's apt repo # explicitly (key + sources.list) instead of `curl | sudo bash` # so we don't execute network-fetched code as root. NODE_MAJOR=24 # GitHub runner images often ship a NodeSource node_20.x list # preinstalled (sometimes as a .sources deb822 file). Wipe any # existing nodesource entries so the only Node candidate apt sees # is our node_24.x repo. Otherwise `apt-get install -y nodejs` # picks the higher-version 20.x build that's already cached and # `dpkg -i` then fails on `Depends: nodejs (>= 22)`. sudo rm -f /etc/apt/sources.list.d/nodesource.list \ /etc/apt/sources.list.d/nodesource.sources \ /etc/apt/preferences.d/nodesource \ /etc/apt/preferences.d/nodesource.pref KEYRING=/usr/share/keyrings/nodesource.gpg curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ | sudo gpg --dearmor --yes -o "${KEYRING}" echo "deb [signed-by=${KEYRING}] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \ | sudo tee /etc/apt/sources.list.d/nodesource.list # Pin nodejs to the 24.x line so neither Ubuntu's noble-updates # 20.x nor any leftover NodeSource cache can win the resolver. printf 'Package: nodejs\nPin: version %s.*\nPin-Priority: 1001\n' "${NODE_MAJOR}" \ | sudo tee /etc/apt/preferences.d/nodesource >/dev/null sudo apt-get update # Pin the major explicitly on the install line as a belt-and- # suspenders guard against any preference file being ignored. sudo apt-get install -y "nodejs=${NODE_MAJOR}.*" # Sanity check before invoking dpkg so the failure mode is obvious # if pinning ever regresses again. installed_major=$(dpkg-query -W -f='${Version}' nodejs | cut -d. -f1) if [ "${installed_major}" != "${NODE_MAJOR}" ]; then echo "::error::Expected nodejs major ${NODE_MAJOR}, got ${installed_major}" apt-cache policy nodejs || true exit 1 fi sudo dpkg -i dist/*.deb || sudo apt-get install -f -y # /etc/etherpad is mode 0750 root:etherpad on purpose (DB creds # live here) so the runner user can't read into it. Each check # that crosses /etc/etherpad or /var/lib/etherpad runs under sudo. sudo test -x /usr/bin/etherpad sudo test -f /etc/etherpad/settings.json sudo test -L /opt/etherpad/settings.json sudo test -L /opt/etherpad/var [ "$(sudo readlink /opt/etherpad/var)" = "/var/lib/etherpad/var" ] sudo test -L /opt/etherpad/src/plugin_packages [ "$(sudo readlink /opt/etherpad/src/plugin_packages)" = "/var/lib/etherpad/plugin_packages" ] sudo test -d /var/lib/etherpad/plugin_packages [ "$(sudo stat -c '%U' /var/lib/etherpad/plugin_packages)" = "etherpad" ] [ "$(stat -c '%G' /opt/etherpad/src/node_modules)" = "etherpad" ] sudo test -f /var/lib/etherpad/var/installed_plugins.json sudo grep -q '"ep_etherpad-lite"' /var/lib/etherpad/var/installed_plugins.json sudo grep -q '"dbType": "sqlite"' /etc/etherpad/settings.json id etherpad systemctl cat etherpad.service sudo systemctl start etherpad 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 --no-pager -n 200 || true exit 1 fi sudo systemctl stop etherpad sudo dpkg --purge etherpad ! id etherpad 2>/dev/null - name: Upload artifact uses: actions/upload-artifact@v7 with: name: etherpad-${{ 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@v8 with: path: dist pattern: etherpad-*-deb merge-multiple: true - name: Create stable "latest" aliases run: | set -euo pipefail # Publish stable filenames alongside the versioned ones so users # can curl https://github.com/.../releases/latest/download/etherpad-latest_amd64.deb # without knowing the version. for f in dist/etherpad_*_amd64.deb; do [ -e "$f" ] && cp "$f" dist/etherpad-latest_amd64.deb done for f in dist/etherpad_*_arm64.deb; do [ -e "$f" ] && cp "$f" dist/etherpad-latest_arm64.deb done ls -la dist/ - name: Attach .deb files to release uses: softprops/action-gh-release@v3 with: files: dist/*.deb fail_on_unmatched_files: true apt-publish: # Generates a signed apt repository (Packages.gz + Release/InRelease) # from the .deb artefacts and cross-pushes it into ether/ether.github.com # under public/apt/. The Next.js site that powers etherpad.org serves # public/ verbatim, so the repo lands at: # # https://etherpad.org/apt/ (apt repo root) # https://etherpad.org/key.asc (public key for `apt-key`/keyring) # # Tag pushes go into the `stable` suite. Required secrets: # APT_SIGNING_KEY ASCII-armoured private key for the Etherpad APT # Repository keypair (fingerprint # 6953FA0C6431F30347D65B03AF0CD687D51A6E63). # SITE_DEPLOY_KEY SSH private key matching a deploy key with write # access on ether/ether.github.com. The site repo # holds the public half. name: Publish apt repository to etherpad.org needs: release if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: - name: Checkout etherpad source (for packaging/apt/key.asc) uses: actions/checkout@v6 with: fetch-depth: 1 - name: Configure deploy key for ether/ether.github.com env: SITE_DEPLOY_KEY: ${{ secrets.SITE_DEPLOY_KEY }} run: | set -euo pipefail if [ -z "${SITE_DEPLOY_KEY:-}" ]; then echo "::error::SITE_DEPLOY_KEY secret is not set on ether/etherpad." echo "::error::Add an SSH deploy key with write access on ether/ether.github.com and store the private key here." exit 1 fi mkdir -p ~/.ssh chmod 700 ~/.ssh printf '%s\n' "${SITE_DEPLOY_KEY}" > ~/.ssh/id_deploy chmod 600 ~/.ssh/id_deploy ssh-keyscan -t ed25519,rsa github.com >> ~/.ssh/known_hosts 2>/dev/null cat > ~/.ssh/config <<'CFG' Host github.com HostName github.com User git IdentityFile ~/.ssh/id_deploy IdentitiesOnly yes CFG chmod 600 ~/.ssh/config - name: Clone ether/ether.github.com run: git clone --depth 1 git@github.com:ether/ether.github.com.git site - uses: actions/download-artifact@v8 with: path: dist pattern: etherpad-*-deb merge-multiple: true - name: Install apt-utils + gpg run: | sudo apt-get update -qq sudo apt-get install -y -qq apt-utils gnupg - name: Import signing key env: APT_SIGNING_KEY: ${{ secrets.APT_SIGNING_KEY }} run: | set -euo pipefail if [ -z "${APT_SIGNING_KEY:-}" ]; then echo "::error::APT_SIGNING_KEY secret is not set; cannot sign Release file." exit 1 fi export GNUPGHOME="$(mktemp -d)" chmod 700 "${GNUPGHOME}" echo "GNUPGHOME=${GNUPGHOME}" >>"${GITHUB_ENV}" printf '%s' "${APT_SIGNING_KEY}" | gpg --batch --import # Sanity check: expected long key id. gpg --list-secret-keys --keyid-format=long | grep -q AF0CD687D51A6E63 - name: Generate apt repo metadata run: | set -euo pipefail REPO=site/public/apt SUITE=stable COMP=main # Wipe any previous repo state so removed versions don't linger # in pool/. Packages.gz is regenerated from whatever is in pool/ # right now, so this is the simplest correct option — alternative # is per-version diffing which is fragile. rm -rf "${REPO}" # We ship one architecture-agnostic suite with per-arch pools. # Layout: apt/dists//main/binary-{amd64,arm64}/ for arch in amd64 arm64; do mkdir -p "${REPO}/pool/main/e/etherpad" "${REPO}/dists/${SUITE}/${COMP}/binary-${arch}" done # Drop the .debs into pool/. The leading-digit pattern # excludes the etherpad-latest_*.deb filename aliases the # release job stages — apt resolves by package name + version, # not filename, so including the alias would create duplicate # Packages entries. (Also defends against any future alias that # accidentally lands on dist/etherpad__.deb.) shopt -s nullglob DEBS=(dist/etherpad_[0-9]*_amd64.deb dist/etherpad_[0-9]*_arm64.deb) shopt -u nullglob # Refuse to publish nothing. Without this, a missing or renamed # build artefact would wipe site/public/apt and push an empty, # signed apt repo — breaking `apt update` for every existing # subscriber until the next successful release. if [ ${#DEBS[@]} -lt 2 ]; then echo "::error::Expected per-arch .deb artifacts in dist/, found ${#DEBS[@]}: ${DEBS[*]:-}" echo "::error::Refusing to publish a partial / empty apt repository." exit 1 fi cp "${DEBS[@]}" "${REPO}/pool/main/e/etherpad/" # Generate per-arch Packages files. ( cd "${REPO}" for arch in amd64 arm64; do apt-ftparchive --arch "${arch}" packages pool/main \ > "dists/${SUITE}/${COMP}/binary-${arch}/Packages" gzip -kf "dists/${SUITE}/${COMP}/binary-${arch}/Packages" done # Generate the suite's Release file. The heredoc lines # MUST start at column 1 — apt parsers reject leading # whitespace on header fields (RFC 822 / Debian control). # printf is used over a heredoc to make that contract # impossible to lose to a future re-indent. printf '%s\n' \ "Origin: Etherpad" \ "Label: Etherpad" \ "Suite: ${SUITE}" \ "Codename: ${SUITE}" \ "Architectures: amd64 arm64" \ "Components: ${COMP}" \ "Description: Etherpad official apt repository (${SUITE} channel)" \ "Date: $(date -Ru)" \ > "dists/${SUITE}/Release" # apt-ftparchive appends checksums. apt-ftparchive release "dists/${SUITE}" >> "dists/${SUITE}/Release" # Sign it (clear-signed InRelease + detached Release.gpg). gpg --default-key AF0CD687D51A6E63 --batch --yes \ --clearsign -o "dists/${SUITE}/InRelease" "dists/${SUITE}/Release" gpg --default-key AF0CD687D51A6E63 --batch --yes \ -abs -o "dists/${SUITE}/Release.gpg" "dists/${SUITE}/Release" ) - name: Stage public key alongside the site run: | # Users curl this to add our key to their keyring before apt update. cp packaging/apt/key.asc site/public/key.asc - name: Commit + push to ether/ether.github.com env: TAG: ${{ github.ref_name }} run: | set -euo pipefail cd site git -c user.email=actions@github.com -c user.name='github-actions[bot]' \ add public/apt public/key.asc if git diff --cached --quiet; then echo "No apt-repo changes to publish." exit 0 fi git -c user.email=actions@github.com -c user.name='github-actions[bot]' \ commit -m "apt: publish Etherpad ${TAG}" git push origin HEAD:master