From 7c3837891b7279b99e36c07fa60c40901231c121 Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 7 Apr 2026 17:38:23 +0100 Subject: [PATCH] feat: migrate npm publish to OIDC trusted publishing (#7401) (#7490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: migrate npm publish to OIDC trusted publishing (#7401) Replaces NPM_TOKEN-based publishing with npm Trusted Publishing over OIDC for both etherpad-lite core and the shared plugin publish template. Tokens no longer expire every 90 days; each publish authenticates via a short-lived OIDC token issued to the GitHub Actions runner. Changes: - bin/plugins/lib/npmpublish.yml: the reusable workflow propagated to every ether/ep_* plugin via the update-plugins cron. Now bumps Node to 22, upgrades npm to >=11.5.1, declares id-token: write, drops NODE_AUTH_TOKEN, and calls `npm publish --provenance --access public` directly (not via pnpm/gnpm wrappers, which obscure the npm CLI version requirement). - bin/plugins/lib/test-and-release.yml: the parent workflow that calls npmpublish.yml as a reusable workflow. Top-level and release-job permissions now grant id-token: write so the OIDC token can flow into the called workflow. - .github/workflows/releaseEtherpad.yml: core's own publish workflow for the ep_etherpad package. Same OIDC migration; keeps the gnpm install + rename steps but switches the final publish to npm. - doc/npm-trusted-publishing.md: explains how trusted publishing works, the one-time per-package setup that has to happen on npmjs.com, requirements (Node 22.14+, npm 11.5.1+, cloud runners), and common errors. The next update-plugins cron run will propagate the new template to every plugin. Once that lands and the trusted publisher is configured on npmjs.com per package, the NPM_TOKEN secret can be removed. Closes #7401 Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add bin/setup-trusted-publishers.sh for bulk OIDC config (#7401) Adds a script that automates the per-package trusted-publisher setup that previously had to be done by clicking through npmjs.com once for each of the 80+ ep_* plugins. Uses the new `npm trust github` CLI (npm >= 11.5.1) so the whole org can be configured in one shot: npm login bin/setup-trusted-publishers.sh The script: - Discovers every non-archived ether/ep_* repo via `gh repo list` - Maps ep_etherpad to the etherpad-lite repo / releaseEtherpad.yml, and every plugin to its same-named repo / test-and-release.yml - Runs `npm trust github --repository / --file --yes` for each package - Supports --dry-run, --packages , and --skip-existing - Verifies npm >= 11.5.1 and that the user is logged in before doing anything destructive Doc updated to feature the script as the recommended setup path, with manual web-UI steps kept as a fallback. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: don't bump CI Node version to 22 for OIDC npm 11.5.1 (the version that ships trusted publishing) actually requires '^20.17.0 || >=22.9.0', not Node 22.14+. The npm docs recommend Node 22 but only because that's what bundles a recent enough npm — installing 'npm@latest' on top of Node 20.17+ works just as well. The repo already requires Node >= 20.0.0 in engines.node and the setup-node@v6 'version: 20' input resolves to the latest 20.x (currently 20.20+), which satisfies npm 11's range. Revert the CI publish workflows from node-version: 22 back to 20 so this PR does not raise the Node bar at all. Doc updated to explain the actual constraint. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/releaseEtherpad.yml | 22 +++- bin/plugins/lib/npmpublish.yml | 39 ++++--- bin/plugins/lib/test-and-release.yml | 8 ++ bin/setup-trusted-publishers.sh | 140 ++++++++++++++++++++++++++ doc/npm-trusted-publishing.md | 125 +++++++++++++++++++++++ 5 files changed, 316 insertions(+), 18 deletions(-) create mode 100755 bin/setup-trusted-publishers.sh create mode 100644 doc/npm-trusted-publishing.md diff --git a/.github/workflows/releaseEtherpad.yml b/.github/workflows/releaseEtherpad.yml index bfe1b2556..e9575a494 100644 --- a/.github/workflows/releaseEtherpad.yml +++ b/.github/workflows/releaseEtherpad.yml @@ -1,6 +1,7 @@ name: releaseEtherpad.yaml permissions: contents: read + id-token: write # for npm OIDC trusted publishing on: workflow_dispatch: @@ -13,6 +14,15 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + # OIDC trusted publishing needs npm >= 11.5.1, which requires + # Node >= 20.17.0. setup-node's `20` resolves to the latest + # 20.x, which satisfies that. + node-version: 20 + registry-url: https://registry.npmjs.org/ + - name: Upgrade npm to >=11.5.1 (required for trusted publishing) + run: npm install -g npm@latest - name: Get pnpm store directory shell: bash run: | @@ -38,8 +48,12 @@ jobs: - name: Rename etherpad working-directory: ./src run: sed -i 's/ep_etherpad-lite/ep_etherpad/g' package.json - - name: Release to npm - run: gnpm publish --no-git-checks + # Use `npm publish` directly (not `gnpm`/`pnpm` wrappers) because OIDC + # trusted publishing requires npm CLI >= 11.5.1 and the wrappers shell + # out to npm; calling npm directly avoids any shim ambiguity. The + # ep_etherpad package must have a trusted publisher configured on + # npmjs.com pointing at this workflow file. See: + # https://docs.npmjs.com/trusted-publishers + - name: Release to npm via OIDC + run: npm publish --provenance --access public working-directory: ./src - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_PRIVATE_TOKEN }} diff --git a/bin/plugins/lib/npmpublish.yml b/bin/plugins/lib/npmpublish.yml index f33e7c939..fe0fbdd3a 100644 --- a/bin/plugins/lib/npmpublish.yml +++ b/bin/plugins/lib/npmpublish.yml @@ -1,5 +1,10 @@ # This workflow will run tests using node and then publish a package to the npm registry when a release is created # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages +# +# Publishing uses npm Trusted Publishing (OIDC) — no NPM_TOKEN secret is +# required. Each package must have a trusted publisher configured on npmjs.com +# pointing at this workflow file. See: +# https://docs.npmjs.com/trusted-publishers name: Node.js Package @@ -9,16 +14,24 @@ on: jobs: publish-npm: runs-on: ubuntu-latest + permissions: + contents: write # for `git push --follow-tags` of the version bump + id-token: write # for npm OIDC trusted publishing steps: - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: + # OIDC trusted publishing needs npm >= 11.5.1, which requires + # Node >= 20.17.0. setup-node's `20` resolves to the latest + # 20.x, which satisfies that. node-version: 20 registry-url: https://registry.npmjs.org/ + - name: Upgrade npm to >=11.5.1 (required for trusted publishing) + run: npm install -g npm@latest - name: Check out Etherpad core - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: ether/etherpad-lite - - uses: pnpm/action-setup@v3 + - uses: pnpm/action-setup@v5 name: Install pnpm with: version: 10 @@ -27,7 +40,7 @@ jobs: shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v4 + - uses: actions/cache@v5 name: Setup pnpm cache with: path: ${{ env.STORE_PATH }} @@ -35,7 +48,7 @@ jobs: restore-keys: | ${{ runner.os }}-pnpm-store- - - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - @@ -63,12 +76,10 @@ jobs: # already-used version number. By running `npm publish` after `git push`, # back-to-back merges will cause the first merge's workflow to fail but # the second's will succeed. - - - run: pnpm publish - env: - NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} - #- - # name: Add package to etherpad organization - # run: pnpm access grant read-write etherpad:developers - # env: - # NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} + # + # Use `npm publish` directly (not `pnpm publish`) because OIDC trusted + # publishing requires npm CLI >= 11.5.1 and `pnpm publish` shells out to + # whichever `npm` is on PATH; calling `npm` directly avoids any shim + # ambiguity. + - name: Publish to npm via OIDC + run: npm publish --provenance --access public diff --git a/bin/plugins/lib/test-and-release.yml b/bin/plugins/lib/test-and-release.yml index 22d1c5ea5..85c8552cb 100644 --- a/bin/plugins/lib/test-and-release.yml +++ b/bin/plugins/lib/test-and-release.yml @@ -1,6 +1,11 @@ name: Node.js Package on: [push] +# id-token: write must be granted here so the reusable npmpublish workflow +# can request an OIDC token for npm trusted publishing. +permissions: + contents: write + id-token: write jobs: backend: @@ -14,5 +19,8 @@ jobs: needs: - backend - frontend + permissions: + contents: write # for the version bump push + id-token: write # for npm OIDC trusted publishing uses: ./.github/workflows/npmpublish.yml secrets: inherit diff --git a/bin/setup-trusted-publishers.sh b/bin/setup-trusted-publishers.sh new file mode 100755 index 000000000..60004a201 --- /dev/null +++ b/bin/setup-trusted-publishers.sh @@ -0,0 +1,140 @@ +#!/bin/sh +# +# Configure npm Trusted Publishers (OIDC) for ep_etherpad and every +# ether/ep_* plugin in bulk. +# +# Prerequisites: +# - npm CLI >= 11.5.1 (the version that ships `npm trust github`) +# - Logged into npmjs.com as a maintainer of the packages: `npm login` +# - `gh` CLI logged in (only needed for plugin discovery; pass --packages +# to skip discovery and use a static list) +# +# Usage: +# bin/setup-trusted-publishers.sh # all ether/ep_* plugins + ep_etherpad +# bin/setup-trusted-publishers.sh --dry-run # print what would happen +# bin/setup-trusted-publishers.sh --packages ep_align,ep_webrtc +# bin/setup-trusted-publishers.sh --skip-existing # don't fail if already configured +# +# Each package gets a GitHub Actions trusted publisher pointing at the +# canonical workflow file used by that package family: +# - plugins: .github/workflows/test-and-release.yml +# - ep_etherpad: .github/workflows/releaseEtherpad.yml +# +# Existing configurations cannot be overwritten — only one trust relationship +# per package is allowed today. Use `--skip-existing` to ignore those failures. + +set -eu + +PLUGIN_WORKFLOW=".github/workflows/test-and-release.yml" +CORE_WORKFLOW=".github/workflows/releaseEtherpad.yml" +CORE_PACKAGE="ep_etherpad" +CORE_REPO="etherpad-lite" +ORG="ether" + +DRY_RUN=0 +SKIP_EXISTING=0 +PACKAGES="" + +usage() { + sed -n '2,/^$/p' "$0" | sed 's/^# \?//' + exit "${1:-0}" +} + +# ---------- arg parsing ---------- +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + --skip-existing) SKIP_EXISTING=1; shift ;; + --packages) PACKAGES="$2"; shift 2 ;; + -h|--help) usage 0 ;; + *) printf 'Unknown flag: %s\n' "$1" >&2; usage 1 ;; + esac +done + +# ---------- prereq checks ---------- +is_cmd() { command -v "$1" >/dev/null 2>&1; } + +is_cmd npm || { echo "npm CLI not found." >&2; exit 1; } + +NPM_MAJOR=$(npm --version | cut -d. -f1) +NPM_MINOR=$(npm --version | cut -d. -f2) +NPM_PATCH=$(npm --version | cut -d. -f3) +if [ "$NPM_MAJOR" -lt 11 ] || \ + { [ "$NPM_MAJOR" -eq 11 ] && [ "$NPM_MINOR" -lt 5 ]; } || \ + { [ "$NPM_MAJOR" -eq 11 ] && [ "$NPM_MINOR" -eq 5 ] && [ "$NPM_PATCH" -lt 1 ]; }; then + echo "npm >= 11.5.1 required (you have $(npm --version)). Run: npm install -g npm@latest" >&2 + exit 1 +fi + +# Verify auth (whoami fails if not logged in). Skipped in --dry-run. +if [ "$DRY_RUN" != "1" ]; then + if ! npm whoami >/dev/null 2>&1; then + echo "Not logged into npm. Run 'npm login' first." >&2 + exit 1 + fi +fi + +# ---------- discover packages ---------- +if [ -z "$PACKAGES" ]; then + is_cmd gh || { + echo "gh CLI not found. Either install it or pass --packages ep_a,ep_b,..." >&2 + exit 1 + } + echo "Discovering ether/ep_* repos..." + PACKAGES=$(gh repo list "$ORG" --limit 300 --json name,isArchived \ + --jq '.[] | select(.name | startswith("ep_")) | select(.isArchived | not) | .name' \ + | tr '\n' ',' | sed 's/,$//') + PACKAGES="${CORE_PACKAGE},${PACKAGES}" +fi + +# ---------- per-package setup ---------- +configure_one() { + PKG="$1" + if [ "$PKG" = "$CORE_PACKAGE" ]; then + REPO="$CORE_REPO" + WORKFLOW="$CORE_WORKFLOW" + else + REPO="$PKG" + WORKFLOW="$PLUGIN_WORKFLOW" + fi + + CMD="npm trust github $PKG --repository $ORG/$REPO --file $WORKFLOW --yes" + printf '%-40s -> %s/%s @ %s\n' "$PKG" "$ORG" "$REPO" "$WORKFLOW" + + if [ "$DRY_RUN" = "1" ]; then + printf ' (dry-run) would run: %s\n' "$CMD" + return 0 + fi + + if OUTPUT=$($CMD 2>&1); then + printf ' ok\n' + else + if [ "$SKIP_EXISTING" = "1" ] && \ + echo "$OUTPUT" | grep -qiE "already (exists|configured)"; then + printf ' already configured (skipped)\n' + else + printf ' FAILED:\n%s\n' "$OUTPUT" | sed 's/^/ /' + return 1 + fi + fi +} + +FAILED="" +TOTAL=0 +OK=0 +IFS=',' +for PKG in $PACKAGES; do + TOTAL=$((TOTAL + 1)) + if configure_one "$PKG"; then + OK=$((OK + 1)) + else + FAILED="$FAILED $PKG" + fi +done +unset IFS + +printf '\n%d/%d packages configured\n' "$OK" "$TOTAL" +if [ -n "$FAILED" ]; then + printf 'Failed:%s\n' "$FAILED" + exit 1 +fi diff --git a/doc/npm-trusted-publishing.md b/doc/npm-trusted-publishing.md new file mode 100644 index 000000000..67812170e --- /dev/null +++ b/doc/npm-trusted-publishing.md @@ -0,0 +1,125 @@ +# npm Trusted Publishing (OIDC) + +Etherpad and every `ether/ep_*` plugin publish to npm using +[npm Trusted Publishing][npm-tp] over OpenID Connect. This eliminates the need +to store, rotate, or accidentally leak long-lived `NPM_TOKEN` secrets — each +publish is authenticated against the GitHub Actions runner with a short-lived +OIDC token instead. + +[npm-tp]: https://docs.npmjs.com/trusted-publishers + +## How it works + +1. The publish workflow declares `permissions: id-token: write`. +2. GitHub Actions issues a signed OIDC token to the runner. +3. The npm CLI (>= 11.5.1) trades that OIDC token for a short-lived publish + credential against npmjs.com. +4. npmjs.com checks the OIDC claims (org, repo, workflow file, branch / + environment) against the package's configured *trusted publisher* and, if + they match, accepts the publish. Provenance attestations are recorded + automatically. + +No `NPM_TOKEN` secret is needed in any plugin or in core. + +## One-time setup per package + +Trusted publishing has to be enabled **once per package**. Use the bundled +script to do every package in one go via the `npm trust` CLI (npm >= 11.5.1): + +```sh +# 1. Make sure npm CLI is recent enough +npm install -g npm@latest + +# 2. Log in to npmjs.com as a maintainer +npm login + +# 3. Bulk-configure every ether/ep_* plugin + ep_etherpad +bin/setup-trusted-publishers.sh + +# Or preview without changing anything +bin/setup-trusted-publishers.sh --dry-run + +# Or target a specific subset +bin/setup-trusted-publishers.sh --packages ep_align,ep_webrtc + +# Or ignore packages that are already configured (the registry only allows +# one trust relationship per package today) +bin/setup-trusted-publishers.sh --skip-existing +``` + +The script discovers all non-archived `ether/ep_*` repos via `gh repo list` +and runs `npm trust github --repository / --file +--yes` for each one. `ep_etherpad` is mapped to the `etherpad-lite` repo and +the `releaseEtherpad.yml` workflow; everything else is mapped to its +same-named repo and `test-and-release.yml`. + +If you'd rather click through the npmjs.com UI for a single package: open +`https://www.npmjs.com/package//access` → **Trusted Publisher** → +**Add trusted publisher** → Publisher: GitHub Actions, Organization: `ether`, +Repository: as above, Workflow filename: as above, Environment: blank. + +Once added, the next push to `main`/`master` will publish via OIDC with no +token at all. + +## Migrating an existing package + +If a package previously had an `NPM_TOKEN` secret in CI: + +1. Add the trusted publisher on npmjs.com (steps above). +2. Bump the workflow to the OIDC version — done in + `bin/plugins/lib/npmpublish.yml` (which is propagated to every plugin by + the `update-plugins` workflow). +3. Remove the now-unused `NPM_TOKEN` secret from the GitHub repo settings. + +## Requirements + +- **Node.js**: >= 20.17.0 on the runner. npm 11 requires + `^20.17.0 || >=22.9.0`. The npm docs nominally recommend Node 22.14+, but + Node 20.17+ works fine — the project's `engines.node` already requires + `>=20.0.0`, and `setup-node@v6 with version: 20` resolves to the latest 20.x. +- **npm CLI**: >= 11.5.1. The publish workflow runs `npm install -g npm@latest` + before publishing so the bundled npm version doesn't matter. +- **Runner**: must be a GitHub-hosted (cloud) runner. Self-hosted runners are + not yet supported by npm trusted publishing. +- **`package.json`**: must declare a `repository` field pointing at the + GitHub repo so npm can verify the OIDC claim. Example: + + ```json + { + "repository": { + "type": "git", + "url": "https://github.com/ether/ep_align.git" + } + } + ``` + +## Why call `npm publish` directly? + +The publish workflows run `npm publish --provenance --access public` rather +than `pnpm publish` or `gnpm publish`. Both wrappers shell out to whichever +`npm` is on `PATH`, but they obscure version requirements: trusted publishing +requires npm >= 11.5.1, and going through the wrapper makes it easy to end up +with the wrong CLI version. Invoking `npm` directly removes that ambiguity. + +`pnpm` is still used for everything else (install, build, version bump) — only +the final publish step calls `npm` directly. + +## Troubleshooting + +**`npm error 404 Not Found - PUT https://registry.npmjs.org/`** + +The trusted publisher hasn't been configured on npmjs.com for that package, or +the repository / workflow filename in the trusted publisher config doesn't +match the running workflow. Double-check the workflow filename — it must be the +*basename* of the workflow YAML, not the job name. + +**`npm error code E_OIDC_NO_TOKEN`** + +The workflow is missing `permissions: id-token: write`. Add it to the job +(or to the top-level `permissions:` block). + +**`npm error need: 11.5.1`** + +The runner is using an older bundled npm. The workflow runs +`npm install -g npm@latest` to fix this — make sure that step ran before the +publish step.