Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 75 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
20 changes: 19 additions & 1 deletion framework-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading