diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1e7ac79ed..e7fcee26f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -17,4 +17,11 @@ updates: open-pull-requests-limit: 30 groups: dev-dependencies: - dependency-type: "development" \ No newline at end of file + dependency-type: "development" + cooldown: + default-days: 1 # fallback for anything not covered below + semver-major-days: 7 + semver-minor-days: 3 + semver-patch-days: 1 + include: + - "*" diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 10562bd52..64ba0e90c 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -32,8 +32,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -51,7 +51,7 @@ jobs: cache: pnpm - name: Install libreoffice - uses: awalsh128/cache-apt-pkgs-action@v1.6.0 + uses: awalsh128/cache-apt-pkgs-action@v1.6.3 with: packages: libreoffice libreoffice-pdfimport version: 1.0 @@ -66,24 +66,7 @@ jobs: run: pnpm build - name: Run the backend tests - env: - # --report-on-fatalerror and friends write a Node diagnostic report - # (V8 stack, libuv handles, OS info) on fatal errors that bypass JS - # handlers — the failure mode we've been chasing on Windows + Node - # 24 since PR #7663. Reports land in node-report/ and are uploaded - # as an artifact if the step fails. - NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report" - run: | - mkdir -p "${{ github.workspace }}/node-report" - pnpm test - - name: Upload Node diagnostic reports on failure - if: ${{ failure() }} - uses: actions/upload-artifact@v7 - with: - name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }} - path: node-report/ - if-no-files-found: ignore - retention-days: 7 + run: pnpm test - name: Run the new vitest tests working-directory: src run: pnpm run test:vitest @@ -105,8 +88,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -124,7 +107,7 @@ jobs: cache: pnpm - name: Install libreoffice - uses: awalsh128/cache-apt-pkgs-action@v1.6.0 + uses: awalsh128/cache-apt-pkgs-action@v1.6.3 with: packages: libreoffice libreoffice-pdfimport version: 1.0 @@ -153,19 +136,7 @@ jobs: ep_table_of_contents - name: Run the backend tests - env: - NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report" - run: | - mkdir -p "${{ github.workspace }}/node-report" - pnpm test - - name: Upload Node diagnostic reports on failure - if: ${{ failure() }} - uses: actions/upload-artifact@v7 - with: - name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }} - path: node-report/ - if-no-files-found: ignore - retention-days: 7 + run: pnpm test - name: Run the new vitest tests working-directory: src run: pnpm run test:vitest @@ -180,14 +151,25 @@ jobs: fail-fast: false matrix: # Etherpad requires Node >= 24 (see package.json engines.node). - node: ${{ fromJSON('[24]') }} + # Windows pins Node 24.16.0, not the runner-default 24.15.0. Node 24.15.0's + # bundled libuv (1.51.0) has a stack buffer overrun in the Windows TCP-connect + # path (uv__tcp_connect), proven by a /GS __fastfail full-memory dump under the + # backend suite's heavy localhost connection churn -- the long-standing Windows + # backend silent-ELIFECYCLE flake (memory corruption, so no JS/Node + # observability). Bisect: 24.15.0 crashes (4/4 on 127.0.0.1), 24.16.0 + # (libuv 1.52.1) is clean (0/8). Stays on the 24 LTS line. Pin explicitly + # because setup-node's default check-latest:false reuses the runner's + # pre-cached 24.15.0 for a bare "24". + # Tracking: https://github.com/nodejs/node/issues/63620 — drop this pin + # back to plain "24" once the fix is across the supported 24.x baseline. + node: ${{ fromJSON('["24.16.0"]') }} name: Windows without plugins runs-on: windows-latest steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -218,23 +200,9 @@ jobs: name: Run the backend tests shell: bash working-directory: src - env: - NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report" - run: | - mkdir -p "${{ github.workspace }}/node-report" - # --exit forces process.exit(failures) after the suite completes, - # closing the post-suite event-loop drain window where Windows + - # Node 24 hard-kills the process. Scoped to Windows so Linux/local - # runs still surface real handle leaks via natural drain. - pnpm test -- --exit - - name: Upload Node diagnostic reports on failure - if: ${{ failure() }} - uses: actions/upload-artifact@v7 - with: - name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }} - path: node-report/ - if-no-files-found: ignore - retention-days: 7 + # --exit makes mocha call process.exit() after the run so a leaked handle + # cannot hang the job on Windows. + run: pnpm test -- --exit - name: Run the new vitest tests working-directory: src run: pnpm run test:vitest @@ -248,15 +216,26 @@ jobs: fail-fast: false matrix: # Etherpad requires Node >= 24 (see package.json engines.node). - node: ${{ fromJSON('[24]') }} + # Windows pins Node 24.16.0, not the runner-default 24.15.0. Node 24.15.0's + # bundled libuv (1.51.0) has a stack buffer overrun in the Windows TCP-connect + # path (uv__tcp_connect), proven by a /GS __fastfail full-memory dump under the + # backend suite's heavy localhost connection churn -- the long-standing Windows + # backend silent-ELIFECYCLE flake (memory corruption, so no JS/Node + # observability). Bisect: 24.15.0 crashes (4/4 on 127.0.0.1), 24.16.0 + # (libuv 1.52.1) is clean (0/8). Stays on the 24 LTS line. Pin explicitly + # because setup-node's default check-latest:false reuses the runner's + # pre-cached 24.15.0 for a bare "24". + # Tracking: https://github.com/nodejs/node/issues/63620 — drop this pin + # back to plain "24" once the fix is across the supported 24.x baseline. + node: ${{ fromJSON('["24.16.0"]') }} name: Windows with Plugins runs-on: windows-latest steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -315,23 +294,9 @@ jobs: name: Run the backend tests shell: bash working-directory: src - env: - NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report" - run: | - mkdir -p "${{ github.workspace }}/node-report" - # --exit forces process.exit(failures) after the suite completes, - # closing the post-suite event-loop drain window where Windows + - # Node 24 hard-kills the process. Scoped to Windows so Linux/local - # runs still surface real handle leaks via natural drain. - pnpm test -- --exit - - name: Upload Node diagnostic reports on failure - if: ${{ failure() }} - uses: actions/upload-artifact@v7 - with: - name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }} - path: node-report/ - if-no-files-found: ignore - retention-days: 7 + # --exit makes mocha call process.exit() after the run so a leaked handle + # cannot hang the job on Windows. + run: pnpm test -- --exit - name: Run the new vitest tests working-directory: src run: pnpm run test:vitest diff --git a/.github/workflows/build-and-deploy-docs.yml b/.github/workflows/build-and-deploy-docs.yml index 26d6a5e14..f49cf2974 100644 --- a/.github/workflows/build-and-deploy-docs.yml +++ b/.github/workflows/build-and-deploy-docs.yml @@ -36,15 +36,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ~/.pnpm-store key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - - uses: actions/cache@v5 + - uses: actions/cache@v6 name: Cache vitepress build with: path: doc/.vitepress/cache diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index c6c10937b..37d2d28b7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. diff --git a/.github/workflows/deb-package.yml b/.github/workflows/deb-package.yml index 603089c26..51bad3b93 100644 --- a/.github/workflows/deb-package.yml +++ b/.github/workflows/deb-package.yml @@ -15,6 +15,24 @@ on: - 'src/node/utils/run_cmd.ts' - 'src/static/js/pluginfw/**' - 'settings.json.template' + # Also build + smoke-test the package on PRs that touch the production + # footprint. The smoke step boots the packaged server and waits for /health, + # which is the only check that catches "server starts then exits before + # binding the port" startup regressions (e.g. a dependency bump whose change + # lets the event loop drain mid-boot). Previously this workflow ran only on + # push to develop, so such a regression was caught *after* merge — by which + # point develop was already red. Running it pre-merge blocks the PR instead. + # The release/apt-publish jobs are tag-guarded, so PRs run the build job only. + pull_request: + 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: @@ -44,7 +62,7 @@ jobs: runner: ubuntu-24.04-arm steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref || github.ref }} @@ -328,7 +346,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout etherpad source (for packaging/apt/key.asc) - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 1 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 1fd74e4a5..da2ac6321 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -15,6 +15,6 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Dependency Review' uses: actions/dependency-review-action@v5 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0d1c51a7c..cca0feb45 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: path: etherpad - @@ -42,7 +42,7 @@ jobs: tags: ${{ env.TEST_TAG }} cache-from: type=gha cache-to: type=gha,mode=max - - uses: actions/cache@v5 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -151,6 +151,32 @@ jobs: docker volume rm ep7718-plugins >/dev/null 2>&1 || true [ "$ok" = "1" ] || exit 1 + - + # Regression test: Etherpad container must boot without external + # network access (e.g., air-gapped host or restrictive runtime policy). + name: Regression — boot with disabled network (--network none) + working-directory: etherpad + run: | + docker run --rm -d \ + --network none \ + --name test-offline ${{ env.TEST_TAG }} + docker logs -f test-offline & + ok=0 + for i in $(seq 1 60); do + status=$(docker container inspect -f '{{.State.Health.Status}}' test-offline 2>/dev/null) || { + echo "container exited prematurely while offline" + docker logs test-offline || true + break + } + case ${status} in + healthy) ok=1; echo "offline boot healthy after $((i*2))s"; break;; + starting) sleep 2;; + *) echo "unexpected status: ${status}"; docker logs test-offline || true; break;; + esac + done + docker rm -f test-offline >/dev/null 2>&1 || true + [ "$ok" = "1" ] || exit 1 + build-test-local-plugin: # Regression coverage for #7687: the Docker image's # `bin/installLocalPlugins.sh` step runs as the `etherpad` user and @@ -164,7 +190,7 @@ jobs: steps: - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: path: etherpad - @@ -235,7 +261,7 @@ jobs: steps: - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: path: etherpad - @@ -332,7 +358,7 @@ jobs: steps: - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: path: etherpad - @@ -390,7 +416,7 @@ jobs: enable-url-completion: true - name: Check out ether-charts if: github.ref == 'refs/heads/develop' - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: path: ether-charts repository: ether/ether-charts diff --git a/.github/workflows/downstream-smoke.yml b/.github/workflows/downstream-smoke.yml new file mode 100644 index 000000000..fc42cfe3e --- /dev/null +++ b/.github/workflows/downstream-smoke.yml @@ -0,0 +1,108 @@ +name: Downstream smoke + +# Boots a real Etherpad from the PR and verifies the separate downstream clients +# (Rust terminal editor, Node CLI, desktop/mobile) still round-trip against it. +# Phase 1 lands the boot/healthcheck/self-check/teardown harness + manifest; the +# per-client matrix activates as each client flips `enabled:true` in clients.json. + +on: + pull_request: + paths-ignore: + - "doc/**" + - "docs/**" + schedule: + - cron: '0 4 * * *' # nightly against the default branch + +permissions: + contents: read + +jobs: + smoke: + name: Boot + downstream clients + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + PNPM_HOME: ~/.pnpm-store + APIKEY: downstream-smoke-key + steps: + - name: Checkout core (PR) + uses: actions/checkout@v7 + + - uses: actions/cache@v6 + name: Cache pnpm store + with: + path: ${{ env.PNPM_HOME }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - uses: pnpm/action-setup@v6 + name: Install pnpm + with: + run_install: false + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + - name: Boot Etherpad on :9003 (apikey auth) + run: | + # The template ships a literal "port": 9001 and sso auth; rewrite both. + # (PORT env is ignored once the settings file specifies a port.) + sed -e 's#"port": 9001,#"port": 9003,#' \ + -e 's#${AUTHENTICATION_METHOD:sso}#apikey#' \ + settings.json.template > settings.json + # Fail fast if the template format drifted and sed silently no-op'd. + grep -q '"port": 9003,' settings.json \ + || { echo "::error::port rewrite failed — settings.json.template format changed"; exit 1; } + grep -q '"authenticationMethod": "apikey"' settings.json \ + || { echo "::error::auth rewrite failed — settings.json.template format changed"; exit 1; } + printf '%s' "$APIKEY" > APIKEY.txt + pnpm run prod > /tmp/ep.log 2>&1 & + echo $! > /tmp/ep.pid + echo "booted pid $(cat /tmp/ep.pid)" + + - name: Wait for healthcheck + run: | + for i in $(seq 1 60); do + if curl -fsS "http://localhost:9003/api/" >/dev/null 2>&1; then + echo "server up after ${i} tries"; exit 0 + fi + sleep 2 + done + echo "::error::server did not come up"; tail -50 /tmp/ep.log; exit 1 + + - name: Self-check — authenticated create + read roundtrip + run: | + K="$APIKEY" + curl -fsS "http://localhost:9003/api/1/createPad?apikey=${K}&padID=smoke&text=hi%0A" | tee /tmp/create.json + grep -q '"code":0' /tmp/create.json + curl -fsS "http://localhost:9003/api/1/getText?apikey=${K}&padID=smoke" | tee /tmp/get.json + grep -q '"text":"hi' /tmp/get.json + + - name: Generate canonical wire-vectors + run: cd src && pnpm run vectors:gen + + # Rust toolchain for the `rust`-kind client (etherpad-pad). Other kinds + # use the node+pnpm already set up above. + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Run enabled downstream clients + env: + SMOKE_URL: http://localhost:9003 + SMOKE_APIKEY: ${{ env.APIKEY }} + run: bash src/tests/downstream/run-clients.sh + + - name: Teardown (by PID, never pkill) + if: always() + run: | + if [ -f /tmp/ep.pid ]; then + kill "$(cat /tmp/ep.pid)" 2>/dev/null || true + echo "killed $(cat /tmp/ep.pid)" + fi diff --git a/.github/workflows/frontend-admin-tests.yml b/.github/workflows/frontend-admin-tests.yml index 8d23a23f2..5ff9dea29 100644 --- a/.github/workflows/frontend-admin-tests.yml +++ b/.github/workflows/frontend-admin-tests.yml @@ -27,8 +27,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -48,7 +48,7 @@ jobs: name: Install all dependencies and symlink for ep_etherpad-lite run: pnpm i - name: Cache Playwright browsers - uses: actions/cache@v5 + uses: actions/cache@v6 id: playwright-cache with: path: ~/.cache/ms-playwright diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 19fa6da3f..0def07b51 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -20,15 +20,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - - uses: actions/cache@v5 + - uses: actions/cache@v6 name: Cache Playwright browsers with: path: ~/.cache/ms-playwright @@ -92,15 +92,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - - uses: actions/cache@v5 + - uses: actions/cache@v6 name: Cache Playwright browsers with: path: ~/.cache/ms-playwright @@ -168,15 +168,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - - uses: actions/cache@v5 + - uses: actions/cache@v6 name: Cache Playwright browsers with: path: ~/.cache/ms-playwright @@ -269,15 +269,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - - uses: actions/cache@v5 + - uses: actions/cache@v6 name: Cache Playwright browsers with: path: ~/.cache/ms-playwright diff --git a/.github/workflows/handleRelease.yml b/.github/workflows/handleRelease.yml index 21189a1e6..03ca88667 100644 --- a/.github/workflows/handleRelease.yml +++ b/.github/workflows/handleRelease.yml @@ -27,8 +27,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} diff --git a/.github/workflows/installer-test.yml b/.github/workflows/installer-test.yml index 36634a37a..a40cd9db2 100644 --- a/.github/workflows/installer-test.yml +++ b/.github/workflows/installer-test.yml @@ -23,7 +23,7 @@ jobs: name: shellcheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Run shellcheck on installer.sh run: | sudo apt-get update @@ -38,7 +38,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: @@ -70,13 +70,30 @@ jobs: - name: Smoke test - start Etherpad and curl /api shell: bash + # Hard backstop: if teardown ever fails to reap the server the step + # fails in minutes instead of burning to GitHub's 6h job ceiling. + timeout-minutes: 8 env: ETHERPAD_DIR: ${{ runner.temp }}/etherpad-installer-test run: | set -eu + # Enable job control so the backgrounded launcher gets its own + # process group, letting us reap the whole pnpm -> node tree below. + set -m cd "$ETHERPAD_DIR" pnpm run prod >/tmp/etherpad.log 2>&1 & PID=$! + # `pnpm run prod` is a nested launcher (pnpm -> pnpm --filter -> node), + # so killing $PID alone orphans the node server, which keeps the step's + # output pipe open and hangs CI. Kill the entire process group, with a + # SIGKILL fallback in case SIGTERM is swallowed (e.g. a live flush timer + # keeping the event loop alive), so the step always exits cleanly. + reap() { + kill -TERM "-$PID" 2>/dev/null || true + sleep 5 + kill -KILL "-$PID" 2>/dev/null || true + } + trap reap EXIT # Wait up to 60s for the API to come up. ok=0 for i in $(seq 1 60); do @@ -90,17 +107,14 @@ jobs: if [ "$ok" != "1" ]; then echo "Etherpad did not start within 60s. Last 200 lines of log:" >&2 tail -200 /tmp/etherpad.log >&2 || true - kill "$PID" 2>/dev/null || true exit 1 fi - kill "$PID" 2>/dev/null || true - wait "$PID" 2>/dev/null || true installer-windows: name: end-to-end install (windows-latest) runs-on: windows-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: @@ -131,6 +145,7 @@ jobs: - name: Smoke test - start Etherpad and curl /api shell: pwsh + timeout-minutes: 8 env: ETHERPAD_DIR: ${{ runner.temp }}\etherpad-installer-test run: | diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index 8b1b3f3f8..9b498416c 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -24,8 +24,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -62,8 +62,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -125,8 +125,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} diff --git a/.github/workflows/perform-type-check.yml b/.github/workflows/perform-type-check.yml index b3aeb697c..fbed6df2c 100644 --- a/.github/workflows/perform-type-check.yml +++ b/.github/workflows/perform-type-check.yml @@ -24,8 +24,8 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} diff --git a/.github/workflows/rate-limit.yml b/.github/workflows/rate-limit.yml index c08e6f224..3cd791164 100644 --- a/.github/workflows/rate-limit.yml +++ b/.github/workflows/rate-limit.yml @@ -27,8 +27,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - uses: actions/cache@v5 + uses: actions/checkout@v7 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4d2b5907..b0b697845 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: ether/etherpad-lite path: etherpad @@ -42,12 +42,12 @@ jobs: git checkout develop git reset --hard origin/develop - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: ether/ether.github.com path: ether.github.com token: '${{ secrets.ETHER_RELEASE_TOKEN }}' - - uses: actions/cache@v5 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -82,7 +82,7 @@ jobs: with: ruby-version: 2.7 - - uses: reitzig/actions-asciidoctor@v2.0.4 + - uses: reitzig/actions-asciidoctor@v2.0.5 with: version: 2.0.18 - name: Prepare release diff --git a/.github/workflows/releaseEtherpad.yml b/.github/workflows/releaseEtherpad.yml index a30c0c474..dd53cf9ae 100644 --- a/.github/workflows/releaseEtherpad.yml +++ b/.github/workflows/releaseEtherpad.yml @@ -1,9 +1,33 @@ +# PARKED — npm publish of the core package is not part of the standard release. +# +# This workflow renames `ep_etherpad-lite` -> `ep_etherpad` and publishes +# `./src` to npm. As of 2026-06, that publish serves no load-bearing purpose: +# - `ep_etherpad` has 0 dependents on npm and nothing in this repo depends on it; +# - plugins import `ep_etherpad-lite` resolved from the LOCAL core install, +# and plugin CI clones `ether/etherpad` rather than `npm install`-ing core; +# - Etherpad is run via git clone / Docker / zip / snap, never `npm install`. +# The publish has been failing (E404 PUT — the `ep_etherpad` package has no OIDC +# trusted publisher configured on npmjs.com), which is why npm is stuck at 2.5.0 +# while 3.x shipped fine without it. +# +# It is therefore gated behind an explicit `confirm: true` dispatch input so a +# stray run fails fast with a clear message instead of a confusing 404. To +# actually publish, the npm owner of `ep_etherpad` (samtv12345) must first +# configure a trusted publisher: npmjs.com -> ep_etherpad -> Settings -> +# Trusted Publisher -> repo `ether/etherpad`, workflow `releaseEtherpad.yml`. +# Decision pending: finish that config, or remove this workflow. See AGENTS.MD. name: releaseEtherpad.yaml permissions: contents: read id-token: write # for npm OIDC trusted publishing on: workflow_dispatch: + inputs: + confirm: + description: 'PARKED — publish ep_etherpad to npm? Requires a trusted publisher configured on npmjs.com first (see workflow header). Set true only if that is done.' + required: true + default: false + type: boolean env: PNPM_HOME: ~/.pnpm-store @@ -12,8 +36,16 @@ jobs: release: runs-on: ubuntu-latest steps: + - name: Guard — refuse unless explicitly confirmed + if: ${{ inputs.confirm != true }} + run: | + echo "::error::releaseEtherpad is PARKED. The ep_etherpad npm publish is non-functional" + echo "::error::(no trusted publisher configured on npmjs.com; 0 dependents on npm)." + echo "::error::Re-run with confirm=true only after the owner configures a trusted" + echo "::error::publisher. See the workflow header / AGENTS.MD 'Releasing' section." + exit 1 - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: # OIDC trusted publishing needs npm >= 11.5.1, which requires @@ -22,7 +54,7 @@ jobs: registry-url: https://registry.npmjs.org/ - name: Upgrade npm to >=11.5.1 (required for trusted publishing) run: npm install -g npm@latest - - uses: actions/cache@v5 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} diff --git a/.github/workflows/snap-build.yml b/.github/workflows/snap-build.yml index dcbe923ef..9255db173 100644 --- a/.github/workflows/snap-build.yml +++ b/.github/workflows/snap-build.yml @@ -34,7 +34,7 @@ jobs: name: Wrapper unit tests runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Run snap/tests/run-all.sh run: bash snap/tests/run-all.sh @@ -43,7 +43,7 @@ jobs: needs: wrapper-tests runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install snapcraft run: sudo snap install --classic snapcraft diff --git a/.github/workflows/snap-publish.yml b/.github/workflows/snap-publish.yml index ba9b8a5bf..6f692d27f 100644 --- a/.github/workflows/snap-publish.yml +++ b/.github/workflows/snap-publish.yml @@ -35,7 +35,7 @@ jobs: snap-file: ${{ steps.build.outputs.snap }} steps: - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Build snap id: build diff --git a/.github/workflows/update-plugins.yml b/.github/workflows/update-plugins.yml index 22c1976b6..2f3ca4783 100644 --- a/.github/workflows/update-plugins.yml +++ b/.github/workflows/update-plugins.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out etherpad-lite - uses: actions/checkout@v6 + uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 name: Install pnpm diff --git a/.github/workflows/upgrade-from-latest-release.yml b/.github/workflows/upgrade-from-latest-release.yml index c3ef545f5..02c527e70 100644 --- a/.github/workflows/upgrade-from-latest-release.yml +++ b/.github/workflows/upgrade-from-latest-release.yml @@ -32,13 +32,13 @@ jobs: steps: - name: Check out latest release - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Check out latest release tag run: git checkout "$(git tag --list 'v*' --sort=-version:refname | head -n1)" - - uses: actions/cache@v5 + - uses: actions/cache@v6 name: Cache pnpm store with: path: ${{ env.PNPM_HOME }} @@ -55,7 +55,7 @@ jobs: node-version: ${{ matrix.node }} cache: pnpm - name: Install libreoffice - uses: awalsh128/cache-apt-pkgs-action@v1.6.0 + uses: awalsh128/cache-apt-pkgs-action@v1.6.3 with: packages: libreoffice libreoffice-pdfimport version: 1.0 diff --git a/.pr_agent.toml b/.pr_agent.toml deleted file mode 100644 index 93e106ea2..000000000 --- a/.pr_agent.toml +++ /dev/null @@ -1,5 +0,0 @@ -[pr_reviewer] -run_on_pr_sync = true - -[pr_description] -run_on_pr_sync = true diff --git a/AGENTS.MD b/AGENTS.MD index 5a5fbc4e9..80abdce91 100644 --- a/AGENTS.MD +++ b/AGENTS.MD @@ -211,6 +211,38 @@ pnpm run plugins ls # List installed ### Settings Configured via `settings.json`. A template is available at `settings.json.template`. Environment variables can override any setting using `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`. +## Releasing + +Releases are driven almost entirely by GitHub Actions. A maintainer dispatches **one** workflow; the version bump, tagging, GitHub Release, Docker images, and snap all cascade off the pushed tag. The npm publish is a separate manual dispatch. + +### Prerequisites (check these before dispatching) +- **A `# X.Y.Z` changelog section for the _target_ version must already be at the top of `CHANGELOG.md`.** `bin/release.ts` aborts with `No changelog record for X.Y.Z, please create changelog record` if it's missing. Write the section before dispatching. +- **All four `package.json` files must agree on the current version:** root `package.json`, `src/package.json`, `admin/package.json`, `bin/package.json`. `release.ts` reads the *current* version from **`src/package.json`** and computes the next with `semver.inc(current, type)`. If the files are out of sync (e.g. one was hand-edited), the computed target is wrong and the changelog guard fails. *(This exact desync — `src`/`bin` left at 3.3.0 while root/admin were 3.2.0 — blocked the 3.3.0 release in June 2026.)* + +### Cutting a release +1. **Actions → "Release etherpad"** → Run workflow (`workflow_dispatch`), choose `patch` / `minor` / `major`. Cadence is monthly **minors** (3.0.0 → 3.1.0 → 3.2.0 → …); use `major` only for breaking changes. +2. The **Prepare release** step runs `bin/release.ts`, which: + - sanity-checks the tree (clean working dir, on `develop`, `develop`/`master` upstreams in sync, `../ether.github.com` cloned & clean on `master`); + - bumps the version in all four `package.json` files; + - commits `bump version`, merges `develop` → `master`, creates both `X.Y.Z` **and** `vX.Y.Z` tags, merges `master` back to `develop`; + - builds and stages the versioned docs into the website repo (see **Documentation** below). +3. The **Push after release** step (`bin/push-after-release.sh`) pushes `master`, `develop`, the tag, `--tags`, and the `ether.github.com` docs commit. +4. The pushed **`vX.Y.Z` tag auto-triggers** three workflows: + - `handleRelease.yml` → builds Etherpad, extracts the matching changelog section via `generateChangelog` (`bin/generateReleaseNotes.ts`), and publishes the **GitHub Release** (`make_latest: true`); + - `docker.yml` → builds & pushes the Docker images; + - `snap-publish.yml` → publishes the snap. +5. **npm publish — PARKED, not part of the release.** `releaseEtherpad.yaml` publishes the core as `ep_etherpad` to npm, but that package is **not load-bearing**: it has 0 dependents, nothing depends on it (plugins import `ep_etherpad-lite` from the *local* core install; plugin CI clones the repo), and Etherpad is run via clone/Docker/zip/snap — never `npm install`. The publish currently fails with `E404` because `ep_etherpad` has no OIDC trusted publisher configured on npmjs.com, which is why npm sits at 2.5.0 while 3.x shipped fine without it. The workflow is gated behind a `confirm: true` input so it can't run by accident. **Skip it for a normal release.** To revive it, the npm owner of `ep_etherpad` (`samtv12345`) configures a trusted publisher (npmjs.com → ep_etherpad → Settings → Trusted Publisher → repo `ether/etherpad`, workflow `releaseEtherpad.yml`); otherwise the workflow can be removed. Decision pending. + +### Documentation + +Two distinct things, both important: + +**1. Per-PR doc updates — your responsibility in every behaviour-change PR.** +The `doc/` workspace holds the user/admin/API docs (Markdown + AsciiDoc). Whenever a PR changes API responses, CLI flags, settings keys, hooks, or error formats, update the relevant files in `doc/` **in the same PR** — don't defer it to release time. The HTTP API reference is `doc/api/http_api.{md,adoc}` (keep both in sync). Preview locally with `pnpm run makeDocs`. + +**2. Release-time versioned docs publishing — automated, no manual step.** +During a release, `release.ts` runs `pnpm run makeDocs` (→ `bin/make_docs.ts`) to render `doc/` into `out/doc/`, then copies it into the sibling website repo at `../ether.github.com/public/doc/vX.Y.Z`, bumps that repo's version, and commits `X.Y.Z docs`; `push-after-release.sh` pushes it, publishing the versioned docs on etherpad.org. This requires `ether.github.com` checked out as a **sibling directory** — the **Release etherpad** workflow checks it out automatically. If you ever run `release.ts` by hand, clone it first: `cd .. && git clone git@github.com:ether/ether.github.com.git`. + ## Monorepo Structure This project uses pnpm workspaces. The workspaces are: diff --git a/CHANGELOG.md b/CHANGELOG.md index 27513e9c4..e7721883b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,113 @@ +# 3.3.2 + +3.3.2 is a bug-fix and dependency-hardening follow-up to 3.3.1. It rounds out the pad-deletion UX rework (suppressing the recovery token for durable identities, keeping the token-less Delete button reachable, and closing a read-only deletion hole), restores the saved-revision markers that went missing from in-pad history mode in 3.3.x, and adds env-var overrides so air-gapped installs can switch off Etherpad's outbound calls without editing the image. It also fixes the `migrateDB` / `importSqlFile` / `migrateDirtyDBtoRealDB` CLI scripts against the promise-based ueberdb2 API, rejects unreachable `.`/`..` pad ids, and clears a batch of dependency security advisories (including CVE-2026-54285). On the CI side it unblocks the installer smoke test (which had been hanging the full 6-hour job ceiling since 3.2.0) and pins ueberdb2 past a startup-exit regression in the packaged boot. + +### Security + +- **Force `@opentelemetry/core` ≥ 2.8.0 (GHSA-8988-4f7v-96qf / CVE-2026-54285, #7975).** The transitive dep (pulled in via `@elastic/elasticsearch` → `@elastic/transport`) had a `W3CBaggagePropagator.extract()` that did not enforce W3C size limits on inbound baggage headers, allowing unbounded memory allocation. Pinned via a `pnpm-workspace.yaml` override; satisfies the existing `2.x` range with no parent bump. +- **Resolve open Dependabot security alerts (#7967).** Refreshes stale override floors and adds new ones via `pnpm-workspace` overrides: `form-data` ≥ 4.0.6, `ws` ≥ 8.21.0, `esbuild` ≥ 0.28.1, `basic-ftp` ≥ 5.3.1 (capped `<6.0.0` to avoid a surprise major on the plugin-install path), `tar` ≥ 7.5.16, `js-yaml` ≥ 4.2.0, `qs` ≥ 6.15.2, `ip-address` ≥ 10.1.1, and `@babel/core` ≥ 7.29.6. +- **Reject read-only deletion via token-less paths (part of #7959 / #7960).** Under `allowPadDeletionByAllUsers` a read-only viewer was granted `canDeletePad=true`, and the server's `flagOk`/`creatorOk` branches never checked `session.readonly` — so a read-only link holder could delete a pad without a token. Read-only sessions are now excluded from both the client var and the server's token-less authorization paths; a valid recovery token stays sufficient regardless of session mode. + +### Notable enhancements + +- **Pad deletion — suppress the recovery token for durable identities and relabel the action (#7926 / #7930).** Building on the `allowPadDeletionByAllUsers` suppression, a creator's deletion token is now also withheld when they have a *durable* identity — authenticated (`req.session.user` with a username) **and** the deployment pins that identity to a stable `authorID` via a `getAuthorId` hook — since only then does the creator survive a cookie clear or a different device, making the token redundant. This tightens the previous "require authentication ⇒ always suppress" rule: without `getAuthorId` the authorID still comes from the per-browser cookie, so an authenticated user on a second device is *not* the creator and keeps getting a token. A new `canDeleteWithoutToken` client var hides the whole recovery-token disclosure (label, field, submit) when no token is needed, and the recovery form now renders for all sessions (hidden by default) so an authenticated creator without a durable mapping still has UI to enter their token. `API.createPad` returns a `null` `deletionToken` under `allowPadDeletionByAllUsers`, matching the socket/UI path. +- **Offline/air-gapped installs — env-var overrides for the update check, plugin catalog, and updater (#7917, addresses #7911).** Firewalled deployments could not disable Etherpad's outbound calls without editing `settings.json` inside the image. The relevant keys are now wired through the `${ENV:default}` substitution in `settings.json.docker` and `settings.json.template`: `PRIVACY_UPDATE_CHECK`, `PRIVACY_PLUGIN_CATALOG`, `UPDATES_TIER` (`off` = no calls), `UPDATE_SERVER`, plus the docker-only `UPDATES_SOURCE` / `UPDATES_CHANNEL` / `UPDATES_CHECK_INTERVAL_HOURS` / `UPDATES_GITHUB_REPO` / `UPDATES_REQUIRE_ADMIN_FOR_STATUS`. A new "Updates & privacy" section in `doc/docker.md` documents the set; backend tests parse the shipped configs and fail if the `${ENV}` placeholders are dropped. Config, docs, and tests only — no runtime code change. + +### Notable fixes + +- **Pad — keep the token-less Delete button reachable without pad-wide settings (#7959 / #7960).** The token-less `#delete-pad` button was nested inside the `enablePadWideSettings`-gated section, so disabling pad-wide settings removed the only no-token deletion path — and combined with #7926 hiding the token disclosure when no token is needed, a user allowed to delete could be left with no deletion UI at all. The button is now always rendered (hidden by default) and driven by a `canDeletePad` client var (creator or `allowPadDeletionByAllUsers`, excluding read-only sessions), so the plain button and the recovery-token disclosure are mutually coherent and neither depends on pad-wide settings. +- **History mode — restore the saved-revision markers (#7946 / #7948).** When #7659 moved the timeslider into the pad as an embedded iframe, the user-facing control became the outer `#history-slider-input`, but the saved-revision stars were still drawn into the now-hidden iframe `#ui-slider-bar`, so "Save Revision" appeared to do nothing in in-pad history mode (a 3.3.x regression). `pad_mode.ts` now bridges the embedded slider's saved revisions onto the outer slider as percentage-positioned, aria-hidden star markers (with click-to-seek for mouse users), and the server's `SAVE_REVISION` handler broadcasts `NEW_SAVEDREV` to the pad room so a revision saved by a collaborator appears live on an already-open history slider. A single revision saved at rev 0 now renders too. Adds Playwright coverage for both the single-client and two-client live paths. +- **Import dialog — correct the outdated "no converter" help message (#7988 / #7989).** The notice claimed only plain text and HTML could be imported and linked to the legacy AbiWord wiki, prompting LibreOffice installs for formats that already work natively. Etherpad imports `.txt`, `.html`, `.docx` (via mammoth) and `.etherpad` without LibreOffice; only `.pdf`/`.odt`/`.doc`/`.rtf` still need it. The message now says so and points at the documentation site. +- **PadManager — reject unreachable `.` and `..` pad ids (#7962).** `isValidPadId` accepted ids consisting only of URL dot-segments, but per the WHATWG URL standard a browser normalises `/p/.` to `/p/` and `/p/..` to `/`, so such a pad could be created in the database yet never opened or exported. These ids are now rejected, and the admin `deletePad` handler falls back to a raw key purge when `getPad()` throws so any legacy `.`/`..` pad can still be removed. + +### Internal / contributor-facing + +- **CLI — fix the database migration/import scripts against the ueberdb2 promise API (#7982 / #7983).** `migrateDB.ts` opened source and target databases, copied all keys, then resolved without closing either — so under ueberdb2 6.1.x the keep-alive timer kept the process hanging after "Done syncing dbs", and buffered target writes were only guaranteed flushed on `close()`. It now closes both databases (flushing writes, clearing the timer) on success and error paths and exits with an explicit status. `importSqlFile.ts` and `migrateDirtyDBtoRealDB.ts` were ported off the pre-v6 callback API to `await db.init()` / `db.set(k, v)` / `db.close()`, removing two `@ts-ignore`s that hid broken calls and fixing an undefined `length` in a progress log; `tsc --noEmit` on the bin package is now clean. +- **CI — stop the installer smoke test hanging the 6-hour job ceiling (#7981).** The "Installer test" had hung on every ubuntu/macOS run since 3.2.0: `pnpm run prod` is a nested launcher, so `kill "$PID"; wait "$PID"` only signalled the outer pnpm and blocked forever if the node server didn't exit on SIGTERM. Teardown now runs the launcher in its own process group, kills the whole group (SIGTERM then SIGKILL), drops the blocking `wait`, and adds an 8-minute `timeout-minutes` backstop to both smoke steps. +- **CI — run the Debian-package smoke test on PRs (#7969).** The packaged-boot smoke test previously ran only on push to `develop` — i.e. after merge — which is why the ueberdb2 startup-exit regression turned `develop` red instead of being blocked at PR time. A `pull_request` trigger (scoped to production-footprint paths) now runs the build+smoke job on PRs; the release/apt-publish jobs stay tag-guarded. +- **Release — park the non-functional `ep_etherpad` npm publish (#7922).** The `releaseEtherpad` workflow republished `./src` as `ep_etherpad`, a package with zero dependents that nothing in the repo or any deployment path consumes, and it had been failing with E404 (no OIDC trusted publisher configured). The job is now gated behind an explicit `confirm: true` dispatch input so a stray run fails fast with a clear message, with the status documented in the workflow header and `AGENTS.MD`. +- **Tests — port the orphaned legacy timeslider specs to Playwright (#7949).** The `src/tests/frontend/specs/` mocha suite is run by no CI workflow, so its timeslider coverage was dead — which is how the #7946 history-mode regression reached a release. The still-meaningful cases (revision labels, export links, deep-link entry) were ported to `frontend-new` Playwright specs re-targeted at the real in-pad UI, and the three now-ported legacy specs were deleted. + +### Dependencies + +- `ueberdb2` pinned to `6.1.13`. 6.1.10 rewrote the cache/buffer layer to lazily arm an `.unref()`'d flush timer only when there are dirty keys, so on a fresh empty dirty DB nothing anchored Node's event loop and the packaged (.deb/systemd) boot could exit cleanly (code 0) before `server.listen()` bound the port — failing the Debian-package health check. The dep was pinned back to the last green release (6.1.9, #7969) and then rolled forward to the now-fixed `6.1.13` (#7979), pinned exactly rather than with a caret. +- `nodemailer` 8.x → 9.0.1 (#7965 / #7950 / #7976), `mongodb` 7.1.1 → 7.3.0 (#7941), `pg` 8.21.0 → 8.22.0 (#7985), `undici` → 8.5.0 (#7980 etc.), `oidc-provider` 9.8.4 → 9.8.5 (#7973), `pdfkit` 0.19.0 → 0.19.1 (#7945), `semver` 7.8.3 → 7.8.4 (#7943), and `@radix-ui/react-switch` 1.3.0 → 1.3.1 (#7974). +- Dev/build dependency group updates (#7964, #7970, #7978, #7987, #7944, #7951, #7952, and others), including `@types/node` 25 → 26, `esbuild` 0.28.0 → 0.28.1, `eslint` 10.4.1 → 10.5.0, `@playwright/test` 1.60 → 1.61, `vitest` 4.1.8 → 4.1.9, and `actions/checkout` 6 → 7 (#7977). + +# 3.3.1 + +3.3.1 is a small bug-fix and hardening follow-up to 3.3.0. It closes a stored-XSS vector in the numbered-list `start` attribute, hardens the database layer so a dropped connection to PostgreSQL / Redis / RethinkDB no longer crashes the process (via ueberdb2 6.1.9), and fixes a handful of pad and admin regressions — the iOS dark-mode status bar, the settings language dropdown, the pad-deletion modal under `allowPadDeletionByAllUsers`, and a single unreadable pad blanking the admin Manage-pads list. + +### Security + +- **Pad editor — escape and integer-coerce the numbered-list `start` attribute (GHSA-f7h5-v9hm-548j, #7937).** A crafted `