ci(packaging): publish signed apt repository to etherpad.org/apt (closes #7610) (#7624)

* ci(packaging): publish signed apt repository to etherpad.org/apt (closes #7610)

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_<v>_<arch>.deb)
  and stable-aliased (etherpad-latest_<arch>.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) <noreply@anthropic.com>

* ci(packaging): emit unindented Release headers + tighten artefact glob

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_<v>_… 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_<word>_<arch>.deb`.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-04-29 07:20:00 +08:00 committed by GitHub
parent c55007361c
commit 0a2facb3fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 291 additions and 1 deletions

View File

@ -240,3 +240,171 @@ 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/<suite>/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_<word>_<arch>.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[*]:-<none>}"
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

View File

@ -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:

View File

@ -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 <<EOF
%no-protection
Key-Type: EDDSA
Key-Curve: ed25519
Subkey-Type: ECDH
Subkey-Curve: cv25519
Name-Real: ${NAME_REAL}
Name-Email: ${NAME_EMAIL}
Expire-Date: ${EXPIRE_YEARS}y
%commit
EOF
echo
echo "==> 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: <paste the contents of ${PRIV}>"
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."

14
packaging/apt/key.asc Normal file
View File

@ -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-----