From 09384b6576c68b6619d5322e01e1b73cfdc6883f Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 28 Apr 2026 18:43:52 +0100 Subject: [PATCH 1/2] ci(packaging): publish signed apt repository to etherpad.org/apt (closes #7610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `apt-publish` workflow job that turns the existing `.deb` build artefacts into a signed apt repository hosted at: https://etherpad.org/apt/ End-user install on any Debian/Ubuntu/Mint: curl -fsSL https://etherpad.org/key.asc \ | sudo gpg --dearmor -o /usr/share/keyrings/etherpad.gpg echo "deb [signed-by=/usr/share/keyrings/etherpad.gpg] \ https://etherpad.org/apt stable main" \ | sudo tee /etc/apt/sources.list.d/etherpad.list sudo apt update && sudo apt install etherpad `apt upgrade` works going forward — every tagged release republishes the repo metadata. Change type: patch (CI/distribution; no production behaviour change). ## Why etherpad.org/apt and not ether.github.io/etherpad/apt ether/etherpad's GitHub Pages is already configured as build-from-workflow on `develop` with CNAME `docs.etherpad.org`, and a repo can only have one Pages source. Pushing the apt repo to a gh-pages branch would either be ignored (Pages is reading from the docs workflow) or, if Pages were switched to it, would kill the docs site. ether/ether.github.com is a separate Next.js site that already deploys etherpad.org and serves `public/` verbatim, so cross-pushing the apt repo into `public/apt/` lands it at the canonical Etherpad URL with no infrastructure conflicts. ## What this PR ships 1. `apt-publish` job in `.github/workflows/deb-package.yml`. Runs after `release` on `v*` tag pushes: - Clones ether/ether.github.com over SSH using a deploy key. - Wipes site/public/apt/ and rebuilds it from the per-arch .deb artefacts using apt-ftparchive. - Signs Release + emits InRelease/Release.gpg using the keypair in APT_SIGNING_KEY. - Drops key.asc into site/public/key.asc. - Asserts both per-arch .debs are present before the wipe takes effect — refuses to publish a partial / empty repo if an artefact is missing or renamed. - Commits and pushes to master; the site repo's existing build pipeline picks it up. 2. `packaging/apt/key.asc` — Etherpad APT Repository public key, fingerprint 6953FA0C6431F30347D65B03AF0CD687D51A6E63. Served at https://etherpad.org/key.asc after the next release. 3. `packaging/apt/generate-signing-key.sh` — one-shot helper that generated the keypair, kept for documented future rotation. 4. `packaging/README.md` — apt-repo install recipe is now the recommended path. ## Required secrets before the next tagged release Two secrets on ether/etherpad before the next `v*` tag push: - APT_SIGNING_KEY — ASCII-armoured private key for the Etherpad APT Repository keypair (long key id AF0CD687D51A6E63), generated with packaging/apt/generate-signing-key.sh. - SITE_DEPLOY_KEY — SSH private key. The public half registered as a deploy key with WRITE access on ether/ether.github.com. If either is missing the job fails fast with a clear error. ## What this PR does not change - The release job still attaches both versioned (etherpad__.deb) and stable-aliased (etherpad-latest_.deb) artefacts to the GitHub Release. Anyone pulling from releases/latest/download/etherpad-latest_amd64.deb keeps working. - The build-job smoke test (start under systemd, /health, purge) is unchanged. - docs.etherpad.org is untouched; this PR never pushes to gh-pages. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/deb-package.yml | 162 ++++++++++++++++++++++++++ packaging/README.md | 20 +++- packaging/apt/generate-signing-key.sh | 90 ++++++++++++++ packaging/apt/key.asc | 14 +++ 4 files changed, 285 insertions(+), 1 deletion(-) create mode 100755 packaging/apt/generate-signing-key.sh create mode 100644 packaging/apt/key.asc diff --git a/.github/workflows/deb-package.yml b/.github/workflows/deb-package.yml index 36796326e0b..3017833417a 100644 --- a/.github/workflows/deb-package.yml +++ b/.github/workflows/deb-package.yml @@ -226,3 +226,165 @@ jobs: 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/. Skip the etherpad-latest_*.deb + # filename aliases — apt resolves by package name + version, + # not filename, and including the alias would create a + # duplicate Packages entry. + shopt -s nullglob + DEBS=(dist/etherpad_*_amd64.deb dist/etherpad_*_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. + cat > "dists/${SUITE}/Release" <> "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 diff --git a/packaging/README.md b/packaging/README.md index 1d7d7a28e0b..cb70e699f27 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -53,7 +53,25 @@ packaging/test-local.sh --build-only # just produce dist/*.deb This is the fastest way to validate that the systemd hardening, plugin path symlinks, and tsx wrapper actually work together before pushing. -## Installing +## Installing via the Etherpad apt repository (recommended) + +The release workflow publishes a signed apt repository at +`https://etherpad.org/apt/` on every tagged release. Three lines on +any Debian/Ubuntu/Mint: + +```sh +curl -fsSL https://etherpad.org/key.asc \ + | sudo gpg --dearmor -o /usr/share/keyrings/etherpad.gpg +echo "deb [signed-by=/usr/share/keyrings/etherpad.gpg] https://etherpad.org/apt stable main" \ + | sudo tee /etc/apt/sources.list.d/etherpad.list +sudo apt update && sudo apt install etherpad +``` + +`apt upgrade` works going forward. Repo metadata is signed with the +GPG keypair documented in `packaging/apt/key.asc` (long key id +`AF0CD687D51A6E63`). + +## Installing a single .deb directly The release page publishes both versioned and stable filenames per arch: diff --git a/packaging/apt/generate-signing-key.sh b/packaging/apt/generate-signing-key.sh new file mode 100755 index 00000000000..71222c76013 --- /dev/null +++ b/packaging/apt/generate-signing-key.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# One-time setup: generate a dedicated GPG keypair for signing the +# Etherpad apt repository's Release/InRelease files. Outputs go into +# ./etherpad-apt-{private,public}.asc in the directory you run this in. +# +# After running this script: +# 1. Paste the *private* key contents into a new GitHub repo/org secret +# called APT_SIGNING_KEY (Settings → Secrets and variables → Actions +# → New repository secret). Then delete the .asc file or move it to +# a password manager — GitHub is the canonical store. +# 2. Hand the *public* key contents to whoever is wiring up the apt +# workflow; it gets committed at packaging/apt/key.asc so end users +# can pull it from https://ether.github.io/etherpad/key.asc. +# 3. Note the printed long key ID — the workflow uses it as +# --default-key for `gpg --clearsign`. + +set -euo pipefail + +NAME_REAL="${NAME_REAL:-Etherpad APT Repository}" +NAME_EMAIL="${NAME_EMAIL:-contact@etherpad.org}" +EXPIRE_YEARS="${EXPIRE_YEARS:-5}" + +OUT_DIR="$(pwd)" +PRIV="${OUT_DIR}/etherpad-apt-private.asc" +PUB="${OUT_DIR}/etherpad-apt-public.asc" + +if [[ -e "${PRIV}" || -e "${PUB}" ]]; then + echo "!! Output files already exist in ${OUT_DIR}:" >&2 + ls -la "${PRIV}" "${PUB}" 2>/dev/null >&2 || true + echo " Move/delete them first, or set OUT_DIR to a clean directory." >&2 + exit 1 +fi + +if ! command -v gpg >/dev/null 2>&1; then + echo "!! gpg not found. Install with: sudo apt install gnupg" >&2 + exit 1 +fi + +echo "==> Generating Ed25519 signing key for: ${NAME_REAL} <${NAME_EMAIL}>" +echo " Expires in ${EXPIRE_YEARS} years. No passphrase (CI uses it unattended)." + +# Use a temp GNUPGHOME so we don't pollute the user's keyring with a +# CI-only key, and so subsequent re-runs don't need to delete keys. +TMP_GNUPG="$(mktemp -d)" +trap 'rm -rf "${TMP_GNUPG}"' EXIT +chmod 700 "${TMP_GNUPG}" +export GNUPGHOME="${TMP_GNUPG}" + +gpg --batch --gen-key < Key generated. Details:" +gpg --list-secret-keys --keyid-format=long "${NAME_EMAIL}" + +KEY_ID="$(gpg --list-secret-keys --with-colons "${NAME_EMAIL}" \ + | awk -F: '/^sec/ {print $5; exit}')" + +echo +echo "==> Exporting to ${OUT_DIR}/" +gpg --armor --export-secret-keys "${NAME_EMAIL}" > "${PRIV}" +gpg --armor --export "${NAME_EMAIL}" > "${PUB}" +chmod 600 "${PRIV}" +chmod 644 "${PUB}" + +echo +echo "Done." +echo +echo " Private key (UPLOAD AS GITHUB SECRET 'APT_SIGNING_KEY'):" +echo " ${PRIV}" +echo " Public key (commit as packaging/apt/key.asc, hand to me):" +echo " ${PUB}" +echo " Long key ID (note this somewhere; used as --default-key in the workflow):" +echo " ${KEY_ID}" +echo +echo "Next steps:" +echo " 1. Open https://github.com/ether/etherpad/settings/secrets/actions/new" +echo " Name: APT_SIGNING_KEY" +echo " Value: " +echo " 2. Securely store ${PRIV} (password manager) or delete it after upload." +echo " 3. Send me ${PUB} (or its contents) for the public-key commit." diff --git a/packaging/apt/key.asc b/packaging/apt/key.asc new file mode 100644 index 00000000000..6658b8b4808 --- /dev/null +++ b/packaging/apt/key.asc @@ -0,0 +1,14 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEae+WgxYJKwYBBAHaRw8BAQdAlcdLkrHestdHPWsBdAHX/S48DAmIiU9wu9JH +dPZbpmO0LkV0aGVycGFkIEFQVCBSZXBvc2l0b3J5IDxjb250YWN0QGV0aGVycGFk +Lm9yZz6ImQQTFgoAQRYhBGlT+gxkMfMDR9ZbA68M1ofVGm5jBQJp75aDAhsjBQkJ +ZgGABQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEK8M1ofVGm5jerkBAKd2 +PtrZikAXFeUrlM2BLinXFCL6UOTra9tvhjsuM2ZrAP4/5yqSMIVCwiHluyg08Nzd +aUW0YK9hJOKQkgL3RXTHCLg4BGnvloMSCisGAQQBl1UBBQEBB0BEuHcDkjBQCfPH ++zjFwbcPj06ODzuqhHbWDVLdqVhTcQMBCAeIfgQYFgoAJhYhBGlT+gxkMfMDR9Zb +A68M1ofVGm5jBQJp75aDAhsMBQkJZgGAAAoJEK8M1ofVGm5jlYwBAMvcavJ5/PKH +IcAsZt0SLv2NkeRcTd58oadCivcrAi1WAQDugqCn8Og39e64ND7LpUKPuqO/02gD +shfWz77UlCy3Cw== +=Bcop +-----END PGP PUBLIC KEY BLOCK----- From f5d7a3793d162d6e8f3ff23f8757fb19dcd4c515 Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 28 Apr 2026 18:55:14 +0100 Subject: [PATCH 2/2] ci(packaging): emit unindented Release headers + tighten artefact glob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections from a fresh Qodo review of the rebased apt-publish job: 1. The dists/${SUITE}/Release heredoc was indented with the workflow's YAML scope, which means the resulting file had 10-space-prefixed field lines (` Origin: Etherpad`). apt parsers reject any leading whitespace on header fields per RFC 822 / Debian control format, so the entire suite would have failed to parse on `apt update` even before checksums were appended. Replace the heredoc with `printf '%s\n' ...` so the indentation is entirely under workflow control and impossible to break with a future YAML re-indent. 2. Tighten the artefact glob from `etherpad_*_amd64.deb` to `etherpad_[0-9]*_amd64.deb`. The hyphen-separator distinction (etherpad__… vs etherpad-latest_…) already kept the alias out of the array — Qodo's analysis of a duplicate-Packages bug was incorrect. But pinning to a leading-digit version segment makes the contract explicit and defends against any future alias that accidentally lands on `dist/etherpad__.deb`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/deb-package.yml | 38 ++++++++++++++++++------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/.github/workflows/deb-package.yml b/.github/workflows/deb-package.yml index 3017833417a..107434d081c 100644 --- a/.github/workflows/deb-package.yml +++ b/.github/workflows/deb-package.yml @@ -323,12 +323,14 @@ jobs: 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/. Skip the etherpad-latest_*.deb - # filename aliases — apt resolves by package name + version, - # not filename, and including the alias would create a - # duplicate Packages entry. + # 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_*_amd64.deb dist/etherpad_*_arm64.deb) + 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, @@ -348,17 +350,21 @@ jobs: > "dists/${SUITE}/${COMP}/binary-${arch}/Packages" gzip -kf "dists/${SUITE}/${COMP}/binary-${arch}/Packages" done - # Generate the suite's Release file. - cat > "dists/${SUITE}/Release" < "dists/${SUITE}/Release" # apt-ftparchive appends checksums. apt-ftparchive release "dists/${SUITE}" >> "dists/${SUITE}/Release" # Sign it (clear-signed InRelease + detached Release.gpg).