diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb0218771..db84ad330 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,14 @@ name: Auto-release # Packages without needing a fresh changelog file. Cost on public # repos: $0 (Actions has unlimited free minutes for public repos # on ubuntu-latest). +# +# npm auth is TRUSTED PUBLISHING (OIDC), so there is no NPM_TOKEN +# secret to store, rotate, or let expire. Each of the 8 published +# packages carries its own Trusted Publisher configuration on +# npmjs.com naming owner `webjsdev`, repo `webjs`, and workflow +# `release.yml`. Renaming THIS FILE breaks every publish until all +# 8 are updated, and it fails as an opaque 404 on the publish PUT +# rather than as an auth error. See framework-dev.md. on: push: @@ -40,10 +48,15 @@ on: description: 'Only (re)publish the unscoped wrappers (create-webjs, webjsdev) at the current @webjsdev/cli version. Use to recover a release whose wrapper publish did not land.' type: boolean default: false + republish_paths: + description: 'Space or comma separated changelog paths to (re)publish to npm, e.g. "changelog/core/0.7.52.md changelog/server/0.8.66.md". Recovery path for a release whose npm publish failed. Leave empty for a normal run.' + type: string + default: '' permissions: contents: write # gh release create needs write access to the repo packages: write # npm publish to GitHub Packages + id-token: write # mint the OIDC credential npm trusted publishing exchanges jobs: release: @@ -56,10 +69,18 @@ jobs: with: fetch-depth: 2 - # setup-node writes an .npmrc with the standard - # //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} line, so - # subsequent `npm publish` invocations pick up the token from - # the env var. + # `registry-url` is REQUIRED for trusted publishing, not optional + # decoration: it writes the .npmrc that points npm at the registry + # the OIDC exchange authenticates against. Removing it breaks the + # exchange even though no token is involved any more. + # + # Node 24 bundles npm 11.19.x, which clears trusted publishing's + # npm >= 11.5.1 floor. Do NOT add `npm install -g npm@latest` to + # "make sure npm is new enough": npm `latest` is 12.x, and npm 12 + # turns on the install-time security defaults (`allowScripts` off), + # which blocks esbuild's postinstall, which means packages/core/dist + # never builds. The bundled 11.19.x satisfies OIDC without opting + # into that. - uses: actions/setup-node@v6 with: node-version: '24' @@ -72,14 +93,46 @@ jobs: id: diff run: | set -euo pipefail - # Two modes: + # Three modes: # 1. push event: diff HEAD~1..HEAD for newly added changelog files. # 2. workflow_dispatch with bootstrap_github_packages=true: list # every changelog/**.md file currently in the tree. The # publish-github-packages.js step is idempotent (skips # already-published versions) so a re-run is safe. + # 3. workflow_dispatch with republish_paths set: publish exactly + # the named files. This is the recovery path for a release + # whose npm publish failed, and it exists because RE-RUNNING + # the failed run does NOT work: a re-run replays the workflow + # file from its ORIGINAL commit, so a run that predates a fix + # to this file never sees the fix. Every publish script is + # idempotent, so naming an already-published version is a + # no-op rather than an error. BOOTSTRAP='${{ inputs.bootstrap_github_packages }}' - if [ "$BOOTSTRAP" = 'true' ]; then + REPUBLISH='${{ inputs.republish_paths }}' + if [ -n "$REPUBLISH" ]; then + # Validate every path BEFORE publishing anything: a typo that + # silently published nothing would look identical to success. + mapfile -t REQUESTED < <(printf '%s' "$REPUBLISH" | tr ', ' '\n\n' | grep -v '^$') + for f in "${REQUESTED[@]}"; do + if [ ! -f "$f" ]; then + echo "republish_paths: no such changelog file: $f" >&2 + exit 1 + fi + done + # Sort the requested set by `date:` ASC exactly as the push path + # does, so core still publishes before server and the npm + # `latest` tag lands on the newest version rather than whichever + # was typed last. + mapfile -t NEW < <( + printf '%s\n' "${REQUESTED[@]}" \ + | while read -r f; do + ts=$(awk '/^date:/ { print $2; exit }' "$f") + printf '%s\t%s\n' "$ts" "$f" + done \ + | sort -k1,1 -k2,2r \ + | cut -f2- + ) + elif [ "$BOOTSTRAP" = 'true' ]; then mapfile -t NEW < <( find changelog -name '*.md' -not -name 'README.md' \ | while read -r f; do @@ -121,10 +174,19 @@ jobs: echo "count=${#NEW[@]}" >> "$GITHUB_OUTPUT" echo "bootstrap=$BOOTSTRAP" >> "$GITHUB_OUTPUT" + # No NODE_AUTH_TOKEN: this publishes via npm trusted publishing + # (OIDC). The npm CLI detects the Actions OIDC environment and + # exchanges the job's id-token for a short-lived, workflow-scoped + # credential before it would ever fall back to a token, so + # scripts/publish-npm.js needs no change (its auth comment already + # says it relies on standard npm publish token resolution). + # + # Each of the 8 published packages has a Trusted Publisher + # configured on npmjs.com naming owner `webjsdev`, repo `webjs`, + # and workflow `release.yml`. Renaming this FILE therefore breaks + # every publish until those 8 configurations are updated to match. - name: Publish to npm if: steps.diff.outputs.count != '0' && steps.diff.outputs.bootstrap != 'true' - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail while IFS= read -r f; do @@ -182,10 +244,13 @@ jobs: # `lockstep_only` (workflow_dispatch input) runs ONLY this step at # the current CLI_VERSION: the recovery path for a release whose # wrapper publish did not land. + # Also publishes over OIDC, so no NODE_AUTH_TOKEN here either. + # `create-webjs` and `webjsdev` each carry their own Trusted + # Publisher configuration, since trusted publishing is per package + # rather than per repo. This step is the easily-missed second + # consumer of the old NPM_TOKEN secret. - name: Lockstep-publish wrappers to match @webjsdev/cli if: inputs.lockstep_only || (steps.diff.outputs.count != '0' && steps.diff.outputs.bootstrap != 'true') - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail diff --git a/framework-dev.md b/framework-dev.md index 251e6cb51..ec16ba31a 100644 --- a/framework-dev.md +++ b/framework-dev.md @@ -306,7 +306,25 @@ The whole flow is tool-agnostic: the universal pre-commit hook fires for every ` npm runs first; if it fails (auth, network, transient registry error), the GitHub Release step is skipped and the workflow fails. After fixing, a re-run picks up where it left off: the npm-side check makes the completed package a no-op and only the missing release lands. -The workflow uses `NPM_TOKEN` (repo secret) and the auto-provisioned `GITHUB_TOKEN`. Free for public repos. +**npm authentication is trusted publishing (OIDC), not a stored token.** The job declares `id-token: write`, and the npm CLI detects the Actions OIDC environment and exchanges that id-token for a short-lived, workflow-scoped credential. There is no `NPM_TOKEN` secret, nothing to rotate, and nothing that expires. The `GITHUB_TOKEN` the GitHub Packages and Releases steps use is auto-provisioned. Free for public repos. + +Three things this couples, all of which break every publish if changed carelessly: + +- **Each of the 8 published packages carries its OWN Trusted Publisher configuration** on npmjs.com (trusted publishing is per package, not per repo), naming owner `webjsdev`, repo `webjs`, and workflow `release.yml`. That includes the two unscoped wrappers, `create-webjs` and `webjsdev`. **Renaming `release.yml`, the repo, or the org breaks publishing until all 8 are updated**, and the failure surfaces as an opaque `404 Not Found - PUT` rather than an auth error. +- **`registry-url` in the `setup-node` step is load-bearing.** It writes the `.npmrc` naming the registry the exchange authenticates against. Dropping it as "only needed for tokens" breaks the exchange. +- **Do not upgrade npm past the bundled 11.19.x.** Trusted publishing needs npm >= 11.5.1, which Node 24 already satisfies. npm `latest` is 12.x, and npm 12 enables install-time security defaults (`allowScripts` off) that block esbuild's postinstall, so `packages/core/dist` never builds. + +A brand-new package generally cannot be created by trusted publishing, so its FIRST publish may need a one-off manual one before its Trusted Publisher configuration takes over. + +**Recovering a release whose npm publish failed: use the `republish_paths` dispatch input, NOT a re-run.** Re-running the failed run does not work, and the reason is easy to miss: a re-run replays the workflow file from its ORIGINAL commit, so a run that predates a fix to `release.yml` never sees the fix, however long ago it was merged. Merging a fix does not re-trigger anything either, since the workflow fires only on a push touching `changelog/**`. So run the workflow from the Actions tab with `republish_paths` set to the changelog files to publish: + +``` +changelog/core/0.7.52.md changelog/core/0.7.53.md changelog/server/0.8.66.md +``` + +Space or comma separated. Every path is validated to exist before anything publishes, because a typo that silently published nothing would look exactly like success. The set is then sorted by its `date:` frontmatter ASC, the same ordering the push path uses, so core still publishes before server and npm's `latest` tag lands on the newest version rather than on whichever path was typed last. Every publish script is idempotent, so naming an already-published version is a no-op. Listing a `cli` changelog also republishes the two unscoped wrappers at that cli version. + +This replaced a `NPM_TOKEN` repo secret that expired at npm's 90-day cap on granular write tokens and silently failed two consecutive releases (#1456). Tokens were a dead end regardless: npm removes direct publishing for bypass-2FA tokens around January 2027, leaving only OIDC or a staged publish that a human approves with 2FA. **When `server` or the scaffold consumes a NEW `@webjsdev/core` export, core MUST publish first.** `packages/server/src/dev/handler.js` and `context.js` import core symbols statically (`setAssetUrlProvider`, `setCspNonceProvider`), and `webjs create` emits an app that imports them too. A server published against an older core dies at module load with `does not provide an export named ...`, and a cli published first makes every freshly scaffolded app 500 on every route. Two things force the right order: