From e69de2ffaf31743f6c015a1c645e913c8e59a436 Mon Sep 17 00:00:00 2001 From: Zack Tanner <1939140+ztanner@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:55:43 -0700 Subject: [PATCH 1/5] [ci]: app-based release workflow (#93245) Moves all release workflows off of a GH PAT and uses an app with a short-lived token instead. Test Plan: Dry run [here](https://github.com/vercel/next.js/actions/runs/24934593874). However, this workflow is blocked until we figure out commit signing for the bot app. Some options: - The bot account generates a signing key and we use it in CI (not great, bypasses the app) - The org bypasses signature verification for the bot user (also not great, requires an exemption rule) - We need to rework the commit step so Lerna does not do the push, and instead trigger it via the app + GH API. This seems like the best option, will be added in a follow-up PR. Note: `create-release-branch` workflow is broken in its current form, as we will not be restoring administrator privileges to adjust environment settings. This will become a manual step in the future. --- .github/workflows/build_and_deploy.yml | 12 ++- .github/workflows/create_release_branch.yml | 30 ++++++-- .../sync_backport_canary_release.yml | 26 ++++++- .github/workflows/trigger_release.yml | 29 +++++++- scripts/check-backport-canary-release.js | 8 +- scripts/create-release-branch.js | 28 +++---- scripts/publish-release.js | 8 +- scripts/release-github-auth.js | 74 +++++++++++++++++++ scripts/start-release.js | 55 +++++++------- 9 files changed, 215 insertions(+), 55 deletions(-) create mode 100644 scripts/release-github-auth.js diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml index 4b1f123910b0..b7d1d535158e 100644 --- a/.github/workflows/build_and_deploy.yml +++ b/.github/workflows/build_and_deploy.yml @@ -548,10 +548,20 @@ jobs: merge-multiple: true path: crates/wasm + - name: Create GitHub App token + id: release-app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: next.js + permission-contents: write + - run: ./scripts/publish-native.js - run: ./scripts/publish-release.js env: - RELEASE_BOT_GITHUB_TOKEN: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} + RELEASE_GITHUB_TOKEN: ${{ steps.release-app-token.outputs.token }} buildPassed: needs: ['deploy-target', 'build', 'build-wasm', 'build-native'] diff --git a/.github/workflows/create_release_branch.yml b/.github/workflows/create_release_branch.yml index e7cf0bc7c181..c618d8879ecd 100644 --- a/.github/workflows/create_release_branch.yml +++ b/.github/workflows/create_release_branch.yml @@ -11,10 +11,6 @@ on: type: string required: true - secrets: - RELEASE_BOT_GITHUB_TOKEN: - required: true - name: Create Release Branch env: @@ -39,13 +35,33 @@ jobs: node-version: 20 check-latest: true + - name: Create GitHub App token + id: release-app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: next.js + permission-contents: write + permission-environments: write + permission-workflows: write + + - name: Get GitHub App user ID + id: release-app-user + run: | + user_id="$(gh api "/users/${{ steps.release-app-token.outputs.app-slug }}[bot]" --jq .id)" + echo "user-id=$user_id" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ steps.release-app-token.outputs.token }} + - name: Clone Next.js repository run: git clone https://github.com/vercel/next.js.git --depth=25 --single-branch --branch ${GITHUB_REF_NAME:-canary} . - name: Check token run: gh auth status env: - GITHUB_TOKEN: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.release-app-token.outputs.token }} # https://github.com/actions/virtual-environments/issues/1187 - name: tune linux network @@ -72,6 +88,8 @@ jobs: - run: node ./scripts/create-release-branch.js --branch-name "${INPUT_BRANCHNAME}" --tag-name "${INPUT_TAGNAME}" env: - RELEASE_BOT_GITHUB_TOKEN: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} + RELEASE_GITHUB_TOKEN: ${{ steps.release-app-token.outputs.token }} + RELEASE_GITHUB_APP_SLUG: ${{ steps.release-app-token.outputs.app-slug }} + RELEASE_GITHUB_APP_USER_ID: ${{ steps.release-app-user.outputs.user-id }} INPUT_BRANCHNAME: ${{ github.event.inputs.branchName }} INPUT_TAGNAME: ${{ github.event.inputs.tagName }} diff --git a/.github/workflows/sync_backport_canary_release.yml b/.github/workflows/sync_backport_canary_release.yml index 3ee8b8d01914..0ba5721600bc 100644 --- a/.github/workflows/sync_backport_canary_release.yml +++ b/.github/workflows/sync_backport_canary_release.yml @@ -84,11 +84,23 @@ jobs: if: steps.precheck.outputs.should_evaluate == 'true' run: pnpm install --frozen-lockfile --ignore-scripts + - name: Create GitHub App token + id: release-app-token + if: steps.precheck.outputs.should_evaluate == 'true' + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: next.js + permission-actions: read + permission-contents: read + - name: Evaluate backport release id: evaluate if: steps.precheck.outputs.should_evaluate == 'true' env: - RELEASE_BOT_GITHUB_TOKEN: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} + RELEASE_GITHUB_TOKEN: ${{ steps.release-app-token.outputs.token }} WORKFLOW_RUN_ID: ${{ github.event_name == 'workflow_dispatch' && inputs.workflowRunId || github.event.workflow_run.id }} HEAD_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.headSha || github.event.workflow_run.head_sha }} HEAD_COMMIT_MESSAGE: ${{ github.event_name == 'workflow_dispatch' && '' || github.event.workflow_run.head_commit.message }} @@ -108,9 +120,19 @@ jobs: if: ${{ needs.evaluate.outputs.should_dispatch == 'true' && ((github.event_name == 'workflow_dispatch' && inputs.dispatch) || (github.event_name == 'workflow_run' && vars.ENABLE_BACKPORT_CANARY_SYNC == 'true')) }} runs-on: ubuntu-latest steps: + - name: Create GitHub App token + id: release-app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: next.js + permission-actions: write + - uses: actions/github-script@v7 with: - github-token: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} + github-token: ${{ steps.release-app-token.outputs.token }} script: | await github.request( "POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches", diff --git a/.github/workflows/trigger_release.yml b/.github/workflows/trigger_release.yml index c7bb8f023831..fe51b1eef5a0 100644 --- a/.github/workflows/trigger_release.yml +++ b/.github/workflows/trigger_release.yml @@ -52,6 +52,24 @@ jobs: node-version: 20 check-latest: true + - name: Create GitHub App token + id: release-app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: next.js + permission-contents: write + + - name: Get GitHub App user ID + id: release-app-user + run: | + user_id="$(gh api "/users/${{ steps.release-app-token.outputs.app-slug }}[bot]" --jq .id)" + echo "user-id=$user_id" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ steps.release-app-token.outputs.token }} + - name: Clone Next.js repository run: git clone https://github.com/vercel/next.js.git --depth=25 --single-branch --branch ${GITHUB_REF_NAME:-canary} . @@ -61,10 +79,13 @@ jobs: # Ignoring failures for now to check if a failure truly implies a failed publish. continue-on-error: true env: - GITHUB_TOKEN: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.release-app-token.outputs.token }} - name: Get commit of the latest tag - run: echo "LATEST_TAG_COMMIT=$(git rev-list -n 1 $(git describe --tags --abbrev=0))" >> $GITHUB_ENV + run: | + git fetch --tags --force --deepen=1000 origin + latest_tag="$(git describe --tags --abbrev=0)" + echo "LATEST_TAG_COMMIT=$(git rev-list -n 1 "$latest_tag")" >> $GITHUB_ENV - name: Get latest commit run: echo "LATEST_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV @@ -102,6 +123,8 @@ jobs: - run: node ./scripts/start-release.js --release-type "${INPUT_RELEASETYPE}" --semver-type "${INPUT_SEMVERTYPE}" env: - RELEASE_BOT_GITHUB_TOKEN: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} + RELEASE_GITHUB_TOKEN: ${{ steps.release-app-token.outputs.token }} + RELEASE_GITHUB_APP_SLUG: ${{ steps.release-app-token.outputs.app-slug }} + RELEASE_GITHUB_APP_USER_ID: ${{ steps.release-app-user.outputs.user-id }} INPUT_RELEASETYPE: ${{ github.event.inputs.releaseType || 'canary' }} INPUT_SEMVERTYPE: ${{ github.event.inputs.semverType }} diff --git a/scripts/check-backport-canary-release.js b/scripts/check-backport-canary-release.js index 5646d79412ac..8081a84cccdc 100644 --- a/scripts/check-backport-canary-release.js +++ b/scripts/check-backport-canary-release.js @@ -4,6 +4,10 @@ const fs = require('fs/promises') const path = require('path') const semver = require('semver') +const { + getGitHubToken, + getGitHubTokenMissingMessage, +} = require('./release-github-auth') const PUBLISH_RELEASE_JOB_NAME = 'Potentially publish release' const TRIGGER_RELEASE_WORKFLOW = 'trigger_release.yml' @@ -132,11 +136,11 @@ async function main() { throw new Error('Missing --head-sha') } - const token = process.env.RELEASE_BOT_GITHUB_TOKEN || process.env.GITHUB_TOKEN + const token = getGitHubToken() || process.env.GITHUB_TOKEN const repoFullName = process.env.GITHUB_REPOSITORY if (!token) { - throw new Error('Missing RELEASE_BOT_GITHUB_TOKEN or GITHUB_TOKEN') + throw new Error(`${getGitHubTokenMissingMessage()} or GITHUB_TOKEN`) } if (!repoFullName) { diff --git a/scripts/create-release-branch.js b/scripts/create-release-branch.js index a7585b5b4b82..d725e6d44cf7 100644 --- a/scripts/create-release-branch.js +++ b/scripts/create-release-branch.js @@ -2,6 +2,12 @@ const fs = require('fs') const path = require('path') const execa = require('execa') +const { + configureGitHubAuth, + getGitHubToken, + getGitHubTokenMissingMessage, + verifyGitHubApiAccess, +} = require('./release-github-auth') async function main() { const args = process.argv @@ -16,25 +22,20 @@ async function main() { throw new Error('tagName value is invalid "' + tagName + '"') } - const githubToken = process.env.RELEASE_BOT_GITHUB_TOKEN + const githubToken = getGitHubToken() if (!githubToken) { - console.log(`Missing RELEASE_BOT_GITHUB_TOKEN`) + console.log(getGitHubTokenMissingMessage()) return } - await execa( - `git remote set-url origin https://nextjs-bot:${githubToken}@github.com/vercel/next.js.git`, - { stdio: 'inherit', shell: true } + await configureGitHubAuth(githubToken) + await verifyGitHubApiAccess( + githubToken, + '/repos/vercel/next.js/environments/release-stable/deployment-branch-policies?per_page=1', + 'release-stable deployment branch policies' ) - await execa(`git config user.name "nextjs-bot"`, { - stdio: 'inherit', - shell: true, - }) - await execa(`git config user.email "it+nextjs-bot@vercel.com"`, { - stdio: 'inherit', - shell: true, - }) + await execa(`git checkout -b "${branchName}"`, { stdio: 'inherit', shell: true, @@ -96,6 +97,7 @@ async function main() { stdio: 'inherit', shell: true, }) + await execa(`git push origin "${branchName}"`, { stdio: 'inherit', shell: true, diff --git a/scripts/publish-release.js b/scripts/publish-release.js index afd61ae798d2..c33f6e0cc071 100755 --- a/scripts/publish-release.js +++ b/scripts/publish-release.js @@ -7,6 +7,10 @@ const semver = require('semver') const { Sema } = require('async-sema') const { execSync } = require('child_process') const fs = require('fs') +const { + getGitHubToken, + getGitHubTokenMissingMessage, +} = require('./release-github-auth') const cwd = process.cwd() @@ -126,10 +130,10 @@ const cwd = process.cwd() } const undraft = async () => { - const githubToken = process.env.RELEASE_BOT_GITHUB_TOKEN + const githubToken = getGitHubToken() if (!githubToken) { - throw new Error(`Missing RELEASE_BOT_GITHUB_TOKEN`) + throw new Error(getGitHubTokenMissingMessage()) } if (isCanary) { diff --git a/scripts/release-github-auth.js b/scripts/release-github-auth.js new file mode 100644 index 000000000000..3eeee297431e --- /dev/null +++ b/scripts/release-github-auth.js @@ -0,0 +1,74 @@ +// @ts-check + +const execa = require('execa') + +const REPO_URL = 'github.com/vercel/next.js.git' + +function getGitHubToken() { + return ( + process.env.RELEASE_GITHUB_TOKEN || process.env.RELEASE_BOT_GITHUB_TOKEN + ) +} + +function getGitHubTokenMissingMessage() { + return 'Missing RELEASE_GITHUB_TOKEN or RELEASE_BOT_GITHUB_TOKEN' +} + +function getGitUser() { + const appSlug = process.env.RELEASE_GITHUB_APP_SLUG + const appUserId = process.env.RELEASE_GITHUB_APP_USER_ID + + if (appSlug && appUserId) { + return { + name: `${appSlug}[bot]`, + email: `${appUserId}+${appSlug}[bot]@users.noreply.github.com`, + } + } + + return { + name: process.env.RELEASE_GITHUB_USER_NAME || 'nextjs-bot', + email: process.env.RELEASE_GITHUB_USER_EMAIL || 'it+nextjs-bot@vercel.com', + } +} + +async function configureGitHubAuth(token) { + const gitUser = getGitUser() + const remoteUrl = `https://x-access-token:${encodeURIComponent( + token + )}@${REPO_URL}` + + await execa('git', ['remote', 'set-url', 'origin', remoteUrl], { + stdio: 'inherit', + }) + await execa('git', ['config', 'user.name', gitUser.name], { + stdio: 'inherit', + }) + await execa('git', ['config', 'user.email', gitUser.email], { + stdio: 'inherit', + }) +} + +async function verifyGitHubApiAccess(token, path, label) { + const response = await fetch(`https://api.github.com${path}`, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + + if (!response.ok) { + throw new Error( + `Failed to verify GitHub API access for ${label} (${response.status}): ${await response.text()}` + ) + } + + console.log(`Verified GitHub API access for ${label}`) +} + +module.exports = { + configureGitHubAuth, + getGitHubToken, + getGitHubTokenMissingMessage, + verifyGitHubApiAccess, +} diff --git a/scripts/start-release.js b/scripts/start-release.js index ea0190ac4346..2c16ef99338a 100644 --- a/scripts/start-release.js +++ b/scripts/start-release.js @@ -2,6 +2,12 @@ const path = require('path') const execa = require('execa') const resolveFrom = require('resolve-from') +const { + configureGitHubAuth, + getGitHubToken, + getGitHubTokenMissingMessage, + verifyGitHubApiAccess, +} = require('./release-github-auth') const SEMVER_TYPES = ['patch', 'minor', 'major'] @@ -33,10 +39,10 @@ async function main() { return } - const githubToken = process.env.RELEASE_BOT_GITHUB_TOKEN + const githubToken = getGitHubToken() if (!githubToken) { - console.log(`Missing RELEASE_BOT_GITHUB_TOKEN`) + console.log(getGitHubTokenMissingMessage()) return } @@ -49,18 +55,12 @@ async function main() { const config = new ConfigStore('release') config.set('token', githubToken) - await execa( - `git remote set-url origin https://nextjs-bot:${githubToken}@github.com/vercel/next.js.git`, - { stdio: 'inherit', shell: true } + await configureGitHubAuth(githubToken) + await verifyGitHubApiAccess( + githubToken, + '/repos/vercel/next.js/releases?per_page=1', + 'release lookup' ) - await execa(`git config user.name "nextjs-bot"`, { - stdio: 'inherit', - shell: true, - }) - await execa(`git config user.email "it+nextjs-bot@vercel.com"`, { - stdio: 'inherit', - shell: true, - }) console.log(`Running pnpm release-${isCanary ? 'canary' : 'stable'}...`) const preleaseType = @@ -70,19 +70,22 @@ async function main() { ? 'preminor' : 'prerelease' - const child = execa( - isCanary - ? `pnpm lerna version ${preleaseType} --preid canary --force-publish -y && pnpm release --pre --skip-questions --show-url` - : isReleaseCandidate - ? `pnpm lerna version ${preleaseType} --preid rc --force-publish -y && pnpm release --pre --skip-questions --show-url` - : isBeta - ? `pnpm lerna version ${preleaseType} --preid beta --force-publish -y && pnpm release --pre --skip-questions --show-url` - : `pnpm lerna version ${semverType} --force-publish -y`, - { - stdio: 'pipe', - shell: true, - } - ) + let command = isCanary + ? `pnpm lerna version ${preleaseType} --preid canary --force-publish -y` + : isReleaseCandidate + ? `pnpm lerna version ${preleaseType} --preid rc --force-publish -y` + : isBeta + ? `pnpm lerna version ${preleaseType} --preid beta --force-publish -y` + : `pnpm lerna version ${semverType} --force-publish -y` + + if (isCanary || isReleaseCandidate || isBeta) { + command += ' && pnpm release --pre --skip-questions --show-url' + } + + const child = execa(command, { + stdio: 'pipe', + shell: true, + }) child.stdout?.pipe(process.stdout) child.stderr?.pipe(process.stderr) From 7b01c98a4a94f8cbb3a3cd02b599490410c5042c Mon Sep 17 00:00:00 2001 From: Tobias Koppers Date: Mon, 27 Apr 2026 17:19:26 +0200 Subject: [PATCH 2/5] [turbopack-trace-server] Performance improvements for span event handling (#93179) ## What? Performance improvements for the turbopack-trace-server, optimizing how span events are stored, sorted, and retrieved. ## Why? When processing large traces, the trace server was spending significant time on: 1. Repeated sorting of events for each render in ExecutionOrder mode 2. Recomputing corrected self time on repeated lookups 3. Memory pressure from very large traces ## How? ### Lazy Sorting with LazySortedVec Introduces a new `LazySortedVec` data structure that stores events unsorted and defers sorting until first read via `Deref`. Uses `UnsafeCell` + `Once` for thread-safe one-time sorting. This avoids repeated sorting overhead during trace ingestion. ### Pre-sorted Span Events by Start Time - `SpanEvent::Child` now stores its `start: Timestamp` alongside the index - `SpanEvent` implements `Ord` to sort by start time (with SelfTime before Child for equal timestamps) - The viewer's `ExecutionOrder` mode no longer needs to sort - events are already in order ### Cached Corrected Self Time - `SpanEvent::SelfTime` now wraps a `SpanEventSelfTime` struct containing an `OnceLock` for `corrected_self_time` - The expensive tree lookup is performed once and cached for subsequent accesses - `SpanEventSelfTimeRef` provides access to the cached value via `corrected_self_time()` ### DROP_SPANS Environment Variable Adds `DROP_SPANS=` env var to skip the first N spans when loading traces: - Useful for reducing memory usage with very large traces - Tracks dropped span IDs to also skip their subsequent events (End, SelfTime, etc.) - Stats output shows dropped span progress (e.g., "1000 spans, 500/1000 dropped") --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/lazy_sorted_vec.rs | 64 +++++++++ .../crates/turbopack-trace-server/src/lib.rs | 1 + .../crates/turbopack-trace-server/src/main.rs | 1 + .../src/reader/turbopack.rs | 70 ++++++++-- .../crates/turbopack-trace-server/src/span.rs | 63 ++++++++- .../turbopack-trace-server/src/span_ref.rs | 122 ++++++++++-------- .../turbopack-trace-server/src/store.rs | 53 ++++---- .../turbopack-trace-server/src/viewer.rs | 4 +- 8 files changed, 273 insertions(+), 105 deletions(-) create mode 100644 turbopack/crates/turbopack-trace-server/src/lazy_sorted_vec.rs diff --git a/turbopack/crates/turbopack-trace-server/src/lazy_sorted_vec.rs b/turbopack/crates/turbopack-trace-server/src/lazy_sorted_vec.rs new file mode 100644 index 000000000000..6b8e5ae0ddf2 --- /dev/null +++ b/turbopack/crates/turbopack-trace-server/src/lazy_sorted_vec.rs @@ -0,0 +1,64 @@ +use std::{cell::UnsafeCell, ops::Deref, sync::Once}; + +pub struct LazySortedVec { + vec: UnsafeCell>, + once: Once, +} + +unsafe impl Send for LazySortedVec where T: Send {} +unsafe impl Sync for LazySortedVec where T: Sync {} + +impl LazySortedVec { + pub fn new() -> Self { + Self { + vec: UnsafeCell::new(Vec::new()), + once: Once::new(), + } + } + + pub fn push(&mut self, value: T) { + self.once = Once::new(); + self.vec.get_mut().push(value); + } + + pub fn retain_unordered(&mut self, f: impl FnMut(&T) -> bool) { + self.vec.get_mut().retain(f); + } + + pub fn iter_mut_unordered(&mut self) -> std::slice::IterMut<'_, T> { + self.vec.get_mut().iter_mut() + } +} + +impl Deref for LazySortedVec { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + let ptr = self.vec.get(); + self.once.call_once(|| { + // SAFETY: The only access to the `vec` is through this `Deref` implementation, or we + // have a `&mut self` which prevents a simultaneous `Deref`. So we can guarantee that + // there are no other accesses to the `vec` while we sort it. + unsafe { &mut *ptr }.sort() + }); + // SAFETY: Returning this reference is safe because the lifetime guarantees that there is no + // `&mut self` that could cause a simultaneous access to the `vec`, and the `Once` + // guarantees that the sorting is complete before we return the reference. + unsafe { &*ptr } + } +} + +impl Default for LazySortedVec { + fn default() -> Self { + Self::new() + } +} + +impl From> for LazySortedVec { + fn from(vec: Vec) -> Self { + Self { + vec: UnsafeCell::new(vec), + once: Once::new(), + } + } +} diff --git a/turbopack/crates/turbopack-trace-server/src/lib.rs b/turbopack/crates/turbopack-trace-server/src/lib.rs index c81848af25cf..126fadc20a2f 100644 --- a/turbopack/crates/turbopack-trace-server/src/lib.rs +++ b/turbopack/crates/turbopack-trace-server/src/lib.rs @@ -17,6 +17,7 @@ use self::{ }; mod bottom_up; +mod lazy_sorted_vec; mod reader; mod self_time_tree; mod server; diff --git a/turbopack/crates/turbopack-trace-server/src/main.rs b/turbopack/crates/turbopack-trace-server/src/main.rs index 96dcb855eb97..9ce2663a5eb5 100644 --- a/turbopack/crates/turbopack-trace-server/src/main.rs +++ b/turbopack/crates/turbopack-trace-server/src/main.rs @@ -12,6 +12,7 @@ use rustc_hash::FxHasher; use self::{reader::TraceReader, server::serve, store_container::StoreContainer}; mod bottom_up; +mod lazy_sorted_vec; mod reader; mod self_time_tree; mod server; diff --git a/turbopack/crates/turbopack-trace-server/src/reader/turbopack.rs b/turbopack/crates/turbopack-trace-server/src/reader/turbopack.rs index 105671d7ec0d..cb73c8392770 100644 --- a/turbopack/crates/turbopack-trace-server/src/reader/turbopack.rs +++ b/turbopack/crates/turbopack-trace-server/src/reader/turbopack.rs @@ -128,6 +128,8 @@ impl InternalRowType<'_> { pub struct TurbopackFormat { store: Arc, id_mapping: FxHashMap, + dropped_ids: FxHashSet, + remaining_ids_to_drop: usize, queued_rows: FxHashMap>>, outdated_spans: FxHashSet, thread_stacks: FxHashMap>, @@ -141,9 +143,15 @@ pub struct TurbopackFormat { impl TurbopackFormat { pub fn new(store: Arc) -> Self { + let drop_ids = std::env::var("DROP_SPANS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or_default(); Self { store, id_mapping: FxHashMap::with_capacity_and_hasher(131_072, Default::default()), + dropped_ids: FxHashSet::with_capacity_and_hasher(drop_ids, Default::default()), + remaining_ids_to_drop: drop_ids, queued_rows: FxHashMap::with_capacity_and_hasher(1_024, Default::default()), outdated_spans: FxHashSet::with_capacity_and_hasher(8_192, Default::default()), thread_stacks: FxHashMap::with_capacity_and_hasher(64, Default::default()), @@ -377,6 +385,16 @@ impl TurbopackFormat { queue: &mut Vec>, ) { let id = if let Some(id) = row.id { + if matches!( + row.ty, + InternalRowType::End { .. } + | InternalRowType::Event { .. } + | InternalRowType::Record { .. } + | InternalRowType::SelfTime { .. } + ) && self.dropped_ids.contains(&id) + { + return; + } if let Some(id) = self.id_mapping.get(&id) { Some(*id) } else { @@ -397,18 +415,28 @@ impl TurbopackFormat { target, values, } => { - let span_id = store.add_span( - id, - ts, - self.interner.intern_cow(target), - self.interner.intern_cow(name), - values - .iter() - .map(|(k, v)| (self.interner.intern(k), self.interner.intern_display(v))) - .collect(), - &mut self.outdated_spans, - ); - self.id_mapping.insert(new_id, span_id); + if self.remaining_ids_to_drop > 0 + && let Some(id) = id + { + self.remaining_ids_to_drop -= 1; + self.dropped_ids.insert(new_id); + self.id_mapping.insert(new_id, id); + } else { + let span_id = store.add_span( + id, + ts, + self.interner.intern_cow(target), + self.interner.intern_cow(name), + values + .iter() + .map(|(k, v)| { + (self.interner.intern(k), self.interner.intern_display(v)) + }) + .collect(), + &mut self.outdated_spans, + ); + self.id_mapping.insert(new_id, span_id); + } if let Some(rows) = self.queued_rows.remove(&new_id) { queue.extend(rows); } @@ -496,7 +524,23 @@ impl TraceFormat for TurbopackFormat { } fn stats(&self) -> String { - format!("{} spans", self.id_mapping.len()) + use std::fmt::Write; + + let spans = self.id_mapping.len(); + let mut stats = format!("{spans} spans"); + + let dropped_spans = self.dropped_ids.len(); + if dropped_spans > 0 { + let total_drop = dropped_spans + self.remaining_ids_to_drop; + write!(stats, ", {dropped_spans}/{total_drop} dropped").unwrap(); + } + + let queued_spans = self.queued_rows.len(); + if queued_spans > 0 { + write!(stats, ", {queued_spans} queued").unwrap(); + } + + stats } fn read(&mut self, mut buffer: &[u8], reuse: &mut Self::Reused) -> Result { diff --git a/turbopack/crates/turbopack-trace-server/src/span.rs b/turbopack/crates/turbopack-trace-server/src/span.rs index a3c9db5f9287..d3c60789f76d 100644 --- a/turbopack/crates/turbopack-trace-server/src/span.rs +++ b/turbopack/crates/turbopack-trace-server/src/span.rs @@ -6,7 +6,7 @@ use std::{ use hashbrown::HashMap; use turbo_rcstr::RcStr; -use crate::timestamp::Timestamp; +use crate::{lazy_sorted_vec::LazySortedVec, timestamp::Timestamp}; pub type SpanIndex = NonZeroUsize; @@ -20,7 +20,8 @@ pub struct Span { pub args: Vec<(RcStr, RcStr)>, // This might change during writing: - pub events: Vec, + /// The list of events sorted by start time + pub events: LazySortedVec, pub is_complete: bool, // These values are computed automatically: @@ -100,10 +101,62 @@ impl Span { } } -#[derive(Copy, Clone, PartialEq, Eq)] +pub struct SpanEventSelfTime { + pub start: Timestamp, + pub end: Timestamp, + pub corrected_self_time: OnceLock, +} + pub enum SpanEvent { - SelfTime { start: Timestamp, end: Timestamp }, - Child { index: SpanIndex }, + SelfTime(SpanEventSelfTime), + Child { start: Timestamp, index: SpanIndex }, +} + +impl SpanEvent { + pub fn self_time(start: Timestamp, end: Timestamp) -> Self { + Self::SelfTime(SpanEventSelfTime { + start, + end, + corrected_self_time: OnceLock::new(), + }) + } + + pub fn start(&self) -> Timestamp { + match self { + SpanEvent::SelfTime(self_time) => self_time.start, + SpanEvent::Child { start, .. } => *start, + } + } +} + +impl PartialEq for SpanEvent { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == std::cmp::Ordering::Equal + } +} + +impl Eq for SpanEvent {} + +impl PartialOrd for SpanEvent { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for SpanEvent { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.start() + .cmp(&other.start()) + .then_with(|| match (self, other) { + (SpanEvent::SelfTime(_), SpanEvent::Child { .. }) => std::cmp::Ordering::Less, + (SpanEvent::Child { .. }, SpanEvent::SelfTime(_)) => std::cmp::Ordering::Greater, + (SpanEvent::SelfTime(a), SpanEvent::SelfTime(b)) => a.end.cmp(&b.end), + ( + SpanEvent::Child { start: _, index: a }, + SpanEvent::Child { start: _, index: b }, + ) => a.cmp(b), + }) + } } #[derive(Clone)] diff --git a/turbopack/crates/turbopack-trace-server/src/span_ref.rs b/turbopack/crates/turbopack-trace-server/src/span_ref.rs index a27d0e0c32fc..0dfb41aa4b72 100644 --- a/turbopack/crates/turbopack-trace-server/src/span_ref.rs +++ b/turbopack/crates/turbopack-trace-server/src/span_ref.rs @@ -13,7 +13,10 @@ use turbo_rcstr::{RcStr, rcstr}; use crate::{ FxIndexMap, bottom_up::build_bottom_up_graph, - span::{Span, SpanEvent, SpanExtra, SpanGraphEvent, SpanIndex, SpanNames, SpanTimeData}, + span::{ + Span, SpanEvent, SpanEventSelfTime, SpanExtra, SpanGraphEvent, SpanIndex, SpanNames, + SpanTimeData, + }, span_bottom_up_ref::SpanBottomUpRef, span_graph_ref::{SpanGraphEventRef, SpanGraphRef, event_map_to_list}, store::{SpanId, Store}, @@ -177,29 +180,33 @@ impl<'a> SpanRef<'a> { 1 } - // TODO(sokra) use events instead of children for visualizing span graphs - #[allow(dead_code)] + /// Events sorted by start time, including self time and children. pub fn events(&self) -> impl DoubleEndedIterator> { - self.span.events.iter().map(|event| match event { - &SpanEvent::SelfTime { start, end } => SpanEventRef::SelfTime { - store: self.store, - start, - end, - }, - SpanEvent::Child { index } => SpanEventRef::Child { - span: SpanRef { - span: &self.store.spans[index.get()], - store: self.store, - index: index.get(), + self.span + .events + .iter() + .map(|event: &'a SpanEvent| match event { + SpanEvent::SelfTime(self_time) => SpanEventRef::SelfTime { + self_time: SpanEventSelfTimeRef { + store: self.store, + self_time, + }, }, - }, - }) + SpanEvent::Child { index, .. } => SpanEventRef::Child { + span: SpanRef { + span: &self.store.spans[index.get()], + store: self.store, + index: index.get(), + }, + }, + }) } + /// Children sorted by start time, excluding self time. pub fn children(&self) -> impl DoubleEndedIterator> + 'a + use<'a> { self.span.events.iter().filter_map(|event| match event { SpanEvent::SelfTime { .. } => None, - SpanEvent::Child { index } => Some(SpanRef { + SpanEvent::Child { index, .. } => Some(SpanRef { span: &self.store.spans[index.get()], store: self.store, index: index.get(), @@ -207,10 +214,11 @@ impl<'a> SpanRef<'a> { }) } + /// Children sorted by start time, excluding self time, in parallel. pub fn children_par(&self) -> impl ParallelIterator> + 'a { self.span.events.par_iter().filter_map(|event| match event { SpanEvent::SelfTime { .. } => None, - SpanEvent::Child { index } => Some(SpanRef { + SpanEvent::Child { index, .. } => Some(SpanRef { span: &self.store.spans[index.get()], store: self.store, index: index.get(), @@ -285,17 +293,11 @@ impl<'a> SpanRef<'a> { .span .events .par_iter() - .filter_map(|event| { - if let SpanEvent::SelfTime { start, end } = event { - let duration = *end - *start; - if !duration.is_zero() { - store.set_max_self_time_lookup(*end); - let corrected_time = - store.self_time_tree.as_ref().map_or(duration, |tree| { - tree.lookup_range_corrected_time(*start, *end) - }); - return Some(corrected_time); - } + .filter_map(|event: &'a SpanEvent| { + if let SpanEvent::SelfTime(self_time) = event { + return Some( + SpanEventSelfTimeRef { store, self_time }.corrected_self_time(), + ); } None }) @@ -558,47 +560,53 @@ impl Debug for SpanRef<'_> { } } -#[allow(dead_code)] -#[derive(Copy, Clone)] -pub enum SpanEventRef<'a> { - SelfTime { - store: &'a Store, - start: Timestamp, - end: Timestamp, - }, - Child { - span: SpanRef<'a>, - }, +pub struct SpanEventSelfTimeRef<'a> { + store: &'a Store, + self_time: &'a SpanEventSelfTime, } -impl SpanEventRef<'_> { +impl<'a> SpanEventSelfTimeRef<'a> { pub fn start(&self) -> Timestamp { - match self { - SpanEventRef::SelfTime { start, .. } => *start, - SpanEventRef::Child { span } => span.start(), - } + self.self_time.start } + pub fn end(&self) -> Timestamp { + self.self_time.end + } + + pub fn corrected_self_time(&self) -> Timestamp { + *self.self_time.corrected_self_time.get_or_init(|| { + let duration = self.self_time.end - self.self_time.start; + if !duration.is_zero() { + self.store.set_max_self_time_lookup(self.self_time.end); + self.store.self_time_tree.as_ref().map_or(duration, |tree| { + tree.lookup_range_corrected_time(self.self_time.start, self.self_time.end) + }) + } else { + Timestamp::ZERO + } + }) + } +} + +pub enum SpanEventRef<'a> { + SelfTime { self_time: SpanEventSelfTimeRef<'a> }, + Child { span: SpanRef<'a> }, +} + +impl SpanEventRef<'_> { pub fn total_time(&self) -> Timestamp { match self { - SpanEventRef::SelfTime { start, end, .. } => end.saturating_sub(*start), + SpanEventRef::SelfTime { + self_time: event, .. + } => event.end().saturating_sub(event.start()), SpanEventRef::Child { span } => span.total_time(), } } pub fn corrected_self_time(&self) -> Timestamp { match self { - SpanEventRef::SelfTime { store, start, end } => { - let duration = *end - *start; - if !duration.is_zero() { - store.set_max_self_time_lookup(*end); - store.self_time_tree.as_ref().map_or(duration, |tree| { - tree.lookup_range_corrected_time(*start, *end) - }) - } else { - Timestamp::ZERO - } - } + SpanEventRef::SelfTime { self_time: event } => event.corrected_self_time(), SpanEventRef::Child { span } => span.corrected_self_time(), } } diff --git a/turbopack/crates/turbopack-trace-server/src/store.rs b/turbopack/crates/turbopack-trace-server/src/store.rs index 1f9465523ac2..74c6f4406d7c 100644 --- a/turbopack/crates/turbopack-trace-server/src/store.rs +++ b/turbopack/crates/turbopack-trace-server/src/store.rs @@ -46,7 +46,7 @@ fn new_root_span() -> Span { category: RcStr::default(), name: rcstr!("(root)"), args: vec![], - events: vec![], + events: Default::default(), is_complete: true, max_depth: OnceLock::new(), self_allocations: 0, @@ -120,7 +120,7 @@ impl Store { category, name, args, - events: vec![], + events: vec![].into(), is_complete: false, max_depth: OnceLock::new(), self_allocations: 0, @@ -152,7 +152,7 @@ impl Store { depth = CUT_OFF_DEPTH - 1; } if depth < CUT_OFF_DEPTH { - parent.events.push(SpanEvent::Child { index: id }); + parent.events.push(SpanEvent::Child { start, index: id }); } parent.start = min(parent.start, start); let span = &mut self.spans[id.get()]; @@ -221,7 +221,7 @@ impl Store { outdated_spans.insert(span_index); time_data.self_time += end - start; time_data.self_end = max(time_data.self_end, end); - span.events.push(SpanEvent::SelfTime { start, end }); + span.events.push(SpanEvent::self_time(start, end)); self.insert_self_time(start, end, span_index, outdated_spans); } @@ -249,31 +249,25 @@ impl Store { for (start, end, index) in children { if start > current { if start > self_end { - events.push(SpanEvent::SelfTime { - start: current, - end: self_end, - }); + events.push(SpanEvent::self_time(current, self_end)); self.insert_self_time(current, self_end, span_index, outdated_spans); self_time += self_end - current; break; } - events.push(SpanEvent::SelfTime { - start: current, - end: start, - }); + events.push(SpanEvent::self_time(current, start)); self.insert_self_time(current, start, span_index, outdated_spans); self_time += start - current; } - events.push(SpanEvent::Child { index }); + events.push(SpanEvent::Child { start, index }); current = max(current, end); } current -= start_time; if current < total_time { self_time += total_time - current; - events.push(SpanEvent::SelfTime { - start: current + start_time, - end: start_time + total_time, - }); + events.push(SpanEvent::self_time( + current + start_time, + start_time + total_time, + )); self.insert_self_time( current + start_time, start_time + total_time, @@ -286,7 +280,7 @@ impl Store { let time_data = span.time_data_mut(); time_data.self_time = self_time; time_data.self_end = self_end; - span.events = events; + span.events = events.into(); span.start = start_time; } @@ -298,6 +292,7 @@ impl Store { ) { outdated_spans.insert(span_index); let span = &mut self.spans[span_index.get()]; + let span_start = span.start; let old_parent = span.parent.replace(parent); let old_parent = if let Some(parent) = old_parent { @@ -306,17 +301,16 @@ impl Store { } else { &mut self.spans[0] }; - if let Some(index) = old_parent - .events - .iter() - .position(|event| *event == SpanEvent::Child { index: span_index }) - { - old_parent.events.remove(index); - } + old_parent.events.retain_unordered( + |event: &SpanEvent| !matches!(event, SpanEvent::Child { index, .. } if *index == span_index), + ); outdated_spans.insert(parent); let parent = &mut self.spans[parent.get()]; - parent.events.push(SpanEvent::Child { index: span_index }); + parent.events.push(SpanEvent::Child { + start: span_start, + index: span_index, + }); } pub fn add_allocation( @@ -397,6 +391,11 @@ impl Store { time_data.corrected_self_time.take(); time_data.corrected_total_time.take(); } + for event in span.events.iter_mut_unordered() { + if let SpanEvent::SelfTime(self_time) = event { + self_time.corrected_self_time.take(); + } + } span.total_allocations.take(); span.total_deallocations.take(); span.total_persistent_allocations.take(); @@ -424,7 +423,7 @@ impl Store { pub fn root_spans(&self) -> impl Iterator> { self.spans[0].events.iter().filter_map(|event| match event { - &SpanEvent::Child { index: id } => Some(SpanRef { + &SpanEvent::Child { index: id, .. } => Some(SpanRef { span: &self.spans[id.get()], store: self, index: id.get(), diff --git a/turbopack/crates/turbopack-trace-server/src/viewer.rs b/turbopack/crates/turbopack-trace-server/src/viewer.rs index f1c1087ea830..337f2af75f5e 100644 --- a/turbopack/crates/turbopack-trace-server/src/viewer.rs +++ b/turbopack/crates/turbopack-trace-server/src/viewer.rs @@ -706,9 +706,7 @@ impl Viewer { (title.to_string(), cat.to_string()) })) } - SortMode::ExecutionOrder => { - Either::Right(span.events().sorted_by_key(|child| child.start())) - } + SortMode::ExecutionOrder => Either::Right(span.events()), }; for child in spans { match child { From 012fd7df4ba12f0699754125dc0226b6c99937a2 Mon Sep 17 00:00:00 2001 From: Zack Tanner <1939140+ztanner@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:31:05 -0700 Subject: [PATCH 3/5] [ci]: trigger signed release commit via API (#93285) Stacked on https://github.com/vercel/next.js/pull/93245 Commit signing is required for anything that lands on `canary`. Our previous workflow of using a PAT to push a commit no longer works, since that would have been an unsigned commit. In #93245 we switched to an app token for release workflow steps. This continues by: - Telling Lerna to bump packages but not commit - Creating a signed commit with the staged changes via GitHub's API - Then running the publish flow Test Plan: Dry run [here](https://github.com/vercel/next.js/actions/runs/25001287978/job/73211826479) Commit [here](https://github.com/vercel/next.js/commit/3a8456a62b56615e2f8c1d1e6e7b4e14ce89e078) --- scripts/release-github-api.js | 349 ++++++++++++++++++++++++++++++++++ scripts/start-release.js | 45 +++-- 2 files changed, 380 insertions(+), 14 deletions(-) create mode 100644 scripts/release-github-api.js diff --git a/scripts/release-github-api.js b/scripts/release-github-api.js new file mode 100644 index 000000000000..3d650a9032db --- /dev/null +++ b/scripts/release-github-api.js @@ -0,0 +1,349 @@ +// @ts-check + +const execa = require('execa') +const fs = require('fs/promises') + +const REPO_API_PATH = '/repos/vercel/next.js' + +async function git(args, options = {}) { + const { captureOutput = false, ...execaOptions } = options + const { stdout } = await execa('git', args, { + stdio: captureOutput ? 'pipe' : 'inherit', + ...execaOptions, + }) + + return typeof stdout === 'string' ? stdout.trim() : stdout +} + +/** + * Call the GitHub REST API with the release app token and include response + * bodies in thrown errors so workflow failures show actionable details. + */ +async function githubRequest(token, method, path, body) { + const response = await fetch(`https://api.github.com${path}`, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }) + + if (!response.ok) { + const responseText = await response.text() + + throw new Error( + `GitHub API ${method} ${path} failed (${response.status}): ${responseText}` + ) + } + + if (response.status === 204) { + return null + } + + return response.json() +} + +/** + * Verify the local Lerna release commit has the version tag implied by + * lerna.json, then return that tag name for GitHub ref creation. + */ +async function getLocalReleaseTagName(commitSha) { + const { version } = JSON.parse(await fs.readFile('lerna.json', 'utf8')) + const expectedTagName = `v${version}` + const tags = String( + await git(['tag', '--points-at', commitSha], { captureOutput: true }) + ) + .split('\n') + .map((tag) => tag.trim()) + .filter(Boolean) + + if (!tags.includes(expectedTagName)) { + throw new Error( + `Expected local Lerna release commit ${commitSha} to be tagged with ${expectedTagName}; found ${tags.join( + ', ' + )}` + ) + } + + return expectedTagName +} + +/** + * Return the local Lerna release commit's single parent so the GitHub-created + * commit can replay the same tree change on top of the same base commit. + */ +async function getSingleParent(commitSha) { + const revList = String( + await git(['rev-list', '--parents', '-n', '1', commitSha], { + captureOutput: true, + }) + ) + // git rev-list --parents emits " ". + const [, ...parents] = revList.split(' ') + + if (parents.length !== 1) { + throw new Error( + `Expected release commit ${commitSha} to have exactly one parent; found ${parents.length}` + ) + } + + return parents[0] +} + +/** + * List paths changed by the local release commit, using "\0" delimiters so + * unusual file names do not affect parsing. + */ +async function getChangedFiles(baseSha, headSha) { + const stdout = await git( + ['diff-tree', '-r', '--name-only', '--no-renames', '-z', baseSha, headSha], + { captureOutput: true, encoding: 'utf8' } + ) + + return String(stdout).split('\0').filter(Boolean) +} + +/** + * Read the Git tree metadata for a path so recreated tree entries preserve + * file modes, blob types, and submodule commit pointers. + */ +async function getTreeEntry(commitSha, filePath) { + const stdout = await git(['ls-tree', '-z', commitSha, '--', filePath], { + captureOutput: true, + encoding: 'utf8', + }) + + if (!stdout) { + return null + } + + // Reuse the existing Git object metadata for this path when building the + // GitHub tree payload. git ls-tree -z emits " \t\0". + const match = /^(\d{6}) (\w+) ([0-9a-f]{40})\t/.exec(String(stdout)) + + if (!match) { + throw new Error(`Failed to parse git tree entry for ${filePath}`) + } + + return { + mode: match[1], + type: match[2], + sha: match[3], + } +} + +/** + * Upload one file from the local release commit as a GitHub blob and return + * the blob SHA for the recreated tree entry. + */ +async function createBlobForFile(token, commitSha, filePath) { + const content = await git(['show', `${commitSha}:${filePath}`], { + captureOutput: true, + encoding: null, + maxBuffer: 1024 * 1024 * 100, + }) + const blob = await githubRequest( + token, + 'POST', + `${REPO_API_PATH}/git/blobs`, + { + content: Buffer.from(content).toString('base64'), + encoding: 'base64', + } + ) + + return blob.sha +} + +/** + * Build a GitHub tree matching the local Lerna release commit, creating new + * blob objects for changed files in parallel while preserving deletions and + * submodules. + */ +async function createTreeFromLocalCommit({ token, baseSha, localReleaseSha }) { + const baseTreeSha = await git(['rev-parse', `${baseSha}^{tree}`], { + captureOutput: true, + }) + const changedFiles = await getChangedFiles(baseSha, localReleaseSha) + + if (changedFiles.length === 0) { + throw new Error(`Release commit ${localReleaseSha} has no file changes`) + } + + const tree = await Promise.all( + changedFiles.map(async (filePath) => { + const treeEntry = await getTreeEntry(localReleaseSha, filePath) + + if (!treeEntry) { + return { + path: filePath, + sha: null, + } + } + + if (treeEntry.type === 'commit') { + return { + path: filePath, + mode: treeEntry.mode, + type: treeEntry.type, + sha: treeEntry.sha, + } + } + + const blobSha = await createBlobForFile(token, localReleaseSha, filePath) + + return { + path: filePath, + mode: treeEntry.mode, + type: treeEntry.type, + sha: blobSha, + } + }) + ) + + const createdTree = await githubRequest( + token, + 'POST', + `${REPO_API_PATH}/git/trees`, + { + base_tree: baseTreeSha, + tree, + } + ) + + return createdTree.sha +} + +/** + * Refresh local refs after the API writes so later release steps see the + * GitHub-signed commit and tag instead of Lerna's unsigned local commit. + */ +async function alignLocalBranchWithGitHubReleaseCommit( + branch, + tagName, + commitSha +) { + const tagExists = await execa( + 'git', + ['show-ref', '--verify', '--quiet', `refs/tags/${tagName}`], + { + stdio: 'ignore', + reject: false, + } + ) + + if (tagExists.exitCode === 0) { + await git(['tag', '-d', tagName]) + } else { + console.log(`Local tag ${tagName} does not exist; skipping delete`) + } + + await git([ + 'fetch', + 'origin', + `refs/heads/${branch}:refs/remotes/origin/${branch}`, + `refs/tags/${tagName}:refs/tags/${tagName}`, + ]) + await git(['reset', '--hard', commitSha]) +} + +/** + * Replace Lerna's local release commit with an equivalent GitHub-signed commit, + * then move the release tag and current branch to that new commit. + */ +async function createGitHubReleaseCommit(token) { + const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD'], { + captureOutput: true, + }) + + if (branch === 'HEAD') { + throw new Error('Cannot create a GitHub release commit from detached HEAD') + } + + const localReleaseSha = await git(['rev-parse', 'HEAD'], { + captureOutput: true, + }) + const baseSha = await getSingleParent(localReleaseSha) + const tagName = await getLocalReleaseTagName(localReleaseSha) + const message = await git(['log', '-1', '--pretty=%B'], { + captureOutput: true, + }) + + console.log( + `Creating GitHub-signed release commit for ${tagName} from local Lerna commit ${localReleaseSha}` + ) + + const treeSha = await createTreeFromLocalCommit({ + token, + baseSha, + localReleaseSha, + }) + const commit = await githubRequest( + token, + 'POST', + `${REPO_API_PATH}/git/commits`, + { + message, + tree: treeSha, + parents: [baseSha], + } + ) + + if (!commit.verification?.verified) { + throw new Error( + `GitHub API created unsigned release commit ${commit.sha}: ${commit.verification?.reason}` + ) + } + + let createdTag = false + + try { + await githubRequest(token, 'POST', `${REPO_API_PATH}/git/refs`, { + ref: `refs/tags/${tagName}`, + sha: commit.sha, + }) + createdTag = true + + await githubRequest( + token, + 'PATCH', + `${REPO_API_PATH}/git/refs/heads/${branch}`, + { + sha: commit.sha, + force: false, + } + ) + } catch (error) { + if (createdTag) { + await githubRequest( + token, + 'DELETE', + `${REPO_API_PATH}/git/refs/tags/${tagName}` + ).catch((deleteError) => { + console.error(`Failed to delete ${tagName} after release failure`) + console.error(deleteError) + }) + } + + throw error + } + + await alignLocalBranchWithGitHubReleaseCommit(branch, tagName, commit.sha) + + console.log( + `Created GitHub-signed release commit ${commit.sha} and tag ${tagName}` + ) + + return { + branch, + sha: commit.sha, + tagName, + } +} + +module.exports = { + createGitHubReleaseCommit, +} diff --git a/scripts/start-release.js b/scripts/start-release.js index 2c16ef99338a..38a0d1bcd409 100644 --- a/scripts/start-release.js +++ b/scripts/start-release.js @@ -8,6 +8,7 @@ const { getGitHubTokenMissingMessage, verifyGitHubApiAccess, } = require('./release-github-auth') +const { createGitHubReleaseCommit } = require('./release-github-api') const SEMVER_TYPES = ['patch', 'minor', 'major'] @@ -70,26 +71,42 @@ async function main() { ? 'preminor' : 'prerelease' - let command = isCanary - ? `pnpm lerna version ${preleaseType} --preid canary --force-publish -y` - : isReleaseCandidate - ? `pnpm lerna version ${preleaseType} --preid rc --force-publish -y` - : isBeta - ? `pnpm lerna version ${preleaseType} --preid beta --force-publish -y` - : `pnpm lerna version ${semverType} --force-publish -y` + const lernaArgs = [ + 'lerna', + 'version', + isCanary || isReleaseCandidate || isBeta ? preleaseType : semverType, + ] - if (isCanary || isReleaseCandidate || isBeta) { - command += ' && pnpm release --pre --skip-questions --show-url' + if (isCanary) { + lernaArgs.push('--preid', 'canary') + } else if (isReleaseCandidate) { + lernaArgs.push('--preid', 'rc') + } else if (isBeta) { + lernaArgs.push('--preid', 'beta') } - const child = execa(command, { - stdio: 'pipe', - shell: true, + lernaArgs.push('--force-publish', '-y', '--no-push') + + const child = execa('pnpm', lernaArgs, { + stdio: 'inherit', }) - child.stdout?.pipe(process.stdout) - child.stderr?.pipe(process.stderr) await child + + await createGitHubReleaseCommit(githubToken) + + if (isCanary || isReleaseCandidate || isBeta) { + const releaseChild = execa( + 'pnpm', + ['release', '--pre', '--skip-questions', '--show-url'], + { + stdio: 'inherit', + } + ) + + await releaseChild + } + console.log('Release process is finished') } From 458b0db353fb028de997b389782e49fcdcb4a893 Mon Sep 17 00:00:00 2001 From: "next-js-bot[bot]" <279046576+next-js-bot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:46:45 +0000 Subject: [PATCH 4/5] v16.3.0-canary.3 --- lerna.json | 2 +- packages/create-next-app/package.json | 4 +-- packages/eslint-config-next/package.json | 6 ++-- packages/eslint-plugin-internal/package.json | 4 +-- packages/eslint-plugin-next/package.json | 4 +-- packages/font/package.json | 4 +-- packages/next-bundle-analyzer/package.json | 4 +-- packages/next-codemod/package.json | 4 +-- packages/next-env/package.json | 4 +-- packages/next-mdx/package.json | 4 +-- packages/next-playwright/package.json | 4 +-- packages/next-plugin-storybook/package.json | 4 +-- packages/next-polyfill-module/package.json | 4 +-- packages/next-polyfill-nomodule/package.json | 4 +-- packages/next-routing/package.json | 4 +-- packages/next-rspack/package.json | 4 +-- packages/next-swc/package.json | 4 +-- packages/next/package.json | 16 +++++------ packages/react-refresh-utils/package.json | 4 +-- packages/third-parties/package.json | 6 ++-- pnpm-lock.yaml | 29 +++++++++----------- 21 files changed, 60 insertions(+), 63 deletions(-) diff --git a/lerna.json b/lerna.json index 84ba552cd6d7..8ab440cfd989 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.3.0-canary.2" + "version": "16.3.0-canary.3" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 3b497df4b9d3..bef1788ff9ee 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "keywords": [ "react", "next", @@ -50,4 +50,4 @@ "engines": { "node": ">=20.9.0" } -} +} \ No newline at end of file diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 807cdd8b8513..2956ca2c1383 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.3.0-canary.2", + "@next/eslint-plugin-next": "16.3.0-canary.3", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -60,4 +60,4 @@ "default": "./dist/parser.js" } } -} +} \ No newline at end of file diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index 962ca4e3d351..37547abc8386 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -1,7 +1,7 @@ { "name": "@next/eslint-plugin-internal", "private": true, - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" @@ -18,4 +18,4 @@ "eslint": "9.37.0" }, "scripts": {} -} +} \ No newline at end of file diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 98c88769ea30..70d5617819e6 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -25,4 +25,4 @@ "types": "tsc --project tsconfig.json --skipLibCheck --declaration --emitDeclarationOnly --esModuleInterop --declarationDir dist", "prepublishOnly": "cd ../../ && turbo run build" } -} +} \ No newline at end of file diff --git a/packages/font/package.json b/packages/font/package.json index 83b4f675f14a..e4a884edff04 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "repository": { "url": "vercel/next.js", "directory": "packages/font" @@ -27,4 +27,4 @@ "fontkit": "2.0.2", "typescript": "6.0.2" } -} +} \ No newline at end of file diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 4199e002cfef..aadc2b2e6cc4 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "main": "index.js", "types": "index.d.ts", "license": "MIT", @@ -14,4 +14,4 @@ "scripts": { "pack-for-isolated-tests": "pnpm pack --out ./packed.tgz" } -} +} \ No newline at end of file diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 84a6a7c33bce..b170eb2ed879 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "license": "MIT", "repository": { "type": "git", @@ -42,4 +42,4 @@ "@types/semver": "7.3.1", "typescript": "6.0.2" } -} +} \ No newline at end of file diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 3e3e20d7b30c..708e92a25b35 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "keywords": [ "react", "next", @@ -34,4 +34,4 @@ "dotenv": "16.3.1", "dotenv-expand": "10.0.0" } -} +} \ No newline at end of file diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 742ea1221f0d..0668c45c2702 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "main": "index.js", "license": "MIT", "repository": { @@ -25,4 +25,4 @@ "scripts": { "pack-for-isolated-tests": "pnpm pack --out ./packed.tgz" } -} +} \ No newline at end of file diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json index 18aa4e0680d8..4ab9d83a1fd6 100644 --- a/packages/next-playwright/package.json +++ b/packages/next-playwright/package.json @@ -1,6 +1,6 @@ { "name": "@next/playwright", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "repository": { "url": "vercel/next.js", "directory": "packages/next-playwright" @@ -29,4 +29,4 @@ "@playwright/test": "1.58.2", "typescript": "6.0.2" } -} +} \ No newline at end of file diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 823672f24e32..3069db48473c 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,9 +1,9 @@ { "name": "@next/plugin-storybook", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" }, "scripts": {} -} +} \ No newline at end of file diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index 38eaaf2a017e..be99699a8883 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", @@ -16,4 +16,4 @@ "devDependencies": { "microbundle": "0.15.0" } -} +} \ No newline at end of file diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index adca45b0c0cd..2f2f6edd7867 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", @@ -19,4 +19,4 @@ "object-assign": "4.1.1", "whatwg-fetch": "3.0.0" } -} +} \ No newline at end of file diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json index fb59342d79ff..f68679c29cd3 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -1,6 +1,6 @@ { "name": "@next/routing", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "keywords": [ "react", "next", @@ -36,4 +36,4 @@ "jest": "^29.5.0", "ts-jest": "^29.1.0" } -} +} \ No newline at end of file diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index 5ff005bd49c6..88fb79d6cc44 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" @@ -12,4 +12,4 @@ "scripts": { "pack-for-isolated-tests": "pnpm pack --out ./packed.tgz" } -} +} \ No newline at end of file diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 8e67f1a1c627..735580f99306 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "private": true, "files": [ "native/" @@ -41,4 +41,4 @@ "cross-env": "6.0.3", "wasm-pack": "0.13.1" } -} +} \ No newline at end of file diff --git a/packages/next/package.json b/packages/next/package.json index d24a71f39195..60f3102409ad 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -101,7 +101,7 @@ ] }, "dependencies": { - "@next/env": "16.3.0-canary.2", + "@next/env": "16.3.0-canary.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -165,11 +165,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.3.0-canary.2", - "@next/polyfill-module": "16.3.0-canary.2", - "@next/polyfill-nomodule": "16.3.0-canary.2", - "@next/react-refresh-utils": "16.3.0-canary.2", - "@next/swc": "16.3.0-canary.2", + "@next/font": "16.3.0-canary.3", + "@next/polyfill-module": "16.3.0-canary.3", + "@next/polyfill-nomodule": "16.3.0-canary.3", + "@next/react-refresh-utils": "16.3.0-canary.3", + "@next/swc": "16.3.0-canary.3", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.58.2", "@rspack/core": "1.6.7", @@ -373,4 +373,4 @@ "engines": { "node": ">=20.9.0" } -} +} \ No newline at end of file diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index 9724c5a1565a..7b928906b264 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", @@ -29,4 +29,4 @@ "react-refresh": "0.12.0", "webpack": "^4 || ^5" } -} +} \ No newline at end of file diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index 21f958ba9e39..b3a4c4e51664 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "16.3.0-canary.2", + "version": "16.3.0-canary.3", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -27,7 +27,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "16.3.0-canary.2", + "next": "16.3.0-canary.3", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "6.0.2" @@ -36,4 +36,4 @@ "next": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0-beta.0", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8653951f8c2..655776c50e4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -320,7 +320,7 @@ importers: version: 5.2.1(eslint@9.37.0(jiti@2.6.1)) eslint-plugin-import: specifier: 2.31.0 - version: 2.31.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.2))(eslint@9.37.0(jiti@2.6.1)) + version: 2.31.0(eslint@9.37.0(jiti@2.6.1)) eslint-plugin-jest: specifier: 27.6.3 version: 27.6.3(eslint@9.37.0(jiti@2.6.1))(jest@29.7.0(@types/node@20.17.6(patch_hash=9c31d25336aea4076b3cb942c35e59fd160c540947f01ea455a060367153f9fd))(babel-plugin-macros@3.1.0))(typescript@6.0.2) @@ -983,7 +983,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 16.3.0-canary.2 + specifier: 16.3.0-canary.3 version: link:../eslint-plugin-next eslint: specifier: '>=9.0.0' @@ -1060,7 +1060,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 16.3.0-canary.2 + specifier: 16.3.0-canary.3 version: link:../next-env '@swc/helpers': specifier: 0.5.15 @@ -1181,19 +1181,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 16.3.0-canary.2 + specifier: 16.3.0-canary.3 version: link:../font '@next/polyfill-module': - specifier: 16.3.0-canary.2 + specifier: 16.3.0-canary.3 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 16.3.0-canary.2 + specifier: 16.3.0-canary.3 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 16.3.0-canary.2 + specifier: 16.3.0-canary.3 version: link:../react-refresh-utils '@next/swc': - specifier: 16.3.0-canary.2 + specifier: 16.3.0-canary.3 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1927,7 +1927,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 16.3.0-canary.2 + specifier: 16.3.0-canary.3 version: link:../next outdent: specifier: 0.8.0 @@ -27477,11 +27477,10 @@ snapshots: - bluebird - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-node@0.3.9)(eslint@9.37.0(jiti@2.6.1)): + eslint-module-utils@2.12.0(eslint-import-resolver-node@0.3.9)(eslint@9.37.0(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.2) eslint: 9.37.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: @@ -27522,7 +27521,7 @@ snapshots: - typescript optional: true - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.2))(eslint@9.37.0(jiti@2.6.1)): + eslint-plugin-import@2.31.0(eslint@9.37.0(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -27533,7 +27532,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.37.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-node@0.3.9)(eslint@9.37.0(jiti@2.6.1)) + eslint-module-utils: 2.12.0(eslint-import-resolver-node@0.3.9)(eslint@9.37.0(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -27544,8 +27543,6 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.2) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -38557,4 +38554,4 @@ snapshots: zwitch@1.0.5: {} - zwitch@2.0.4: {} + zwitch@2.0.4: {} \ No newline at end of file From d2e6b6c0e4972642c06c624134b4b939af2bd6d3 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 27 Apr 2026 18:53:19 +0200 Subject: [PATCH 5/5] Preserve `__NEXT_ERROR_CODE` across the `/_error` page handoff (#93183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When SSR fails in development — whether the failing page is in the App Router or the Pages Router — Next.js falls back to rendering the Pages Router `/_error` page and serializes the original error into `__NEXT_DATA__.err`. The client bootstrap in `packages/next/src/client/ index.tsx` later re-throws a fresh `new Error(initialErr.message)` so that the dev overlay picks it up. The error-code SWC plugin stamps that new `Error` with the generic code mapped to `%s` in `errors.json` because the message argument is an identifier, not a statically-known string, which is how the overlay ended up showing the wrong code for errors like `UseCacheTimeoutError`. The pipeline has two problems on the way to the overlay. First, `errorToJSON` in `packages/next/src/server/render.tsx` only copies the error's standard fields (`name`, `source`, `message`, `stack`, `digest`) into `__NEXT_DATA__.err`, so the real `__NEXT_ERROR_CODE` attached to the thrown error never reaches the client. Second, the subsequent rewrap in `getServerError` at `packages/next/src/server/dev/node-stack- frames.ts` creates yet another `new Error(...)` that the plugin stamps with the same generic code, clobbering anything the caller might have set on the rewrapped instance. This change plumbs the code through. `errorToJSON` now also emits `__NEXT_ERROR_CODE` using `extractNextErrorCode` so the value survives JSON serialization into `__NEXT_DATA__.err`; the type in `packages/next/src/shared/lib/utils.ts` is updated to match. Both rewrap sites — the client bootstrap and `getServerError` — copy the code from the source error onto the fresh `Error` via `Object.defineProperty` with `enumerable: false` and `configurable: true`, matching how the plugin itself stores the property, which overrides the generic code the plugin stamped on the rewrapped instance. The `use-cache-hanging` e2e test snapshot is updated from `E394` to `E236` now that the real code surfaces in the dev overlay. --- packages/next/src/client/index.tsx | 8 ++++++ .../next/src/server/dev/node-stack-frames.ts | 9 +++++++ packages/next/src/server/render.tsx | 2 ++ packages/next/src/shared/lib/utils.ts | 1 + .../ReactRefreshLogBox-app-doc.test.ts | 4 +-- .../acceptance/ReactRefreshLogBox.test.ts | 4 +-- .../server-navigation-error.test.ts | 4 +-- .../client-navigation/rendering.test.ts | 12 ++++----- .../cache-components-errors.test.ts | 26 +++++++++---------- .../undefined-default-export.test.ts | 6 ++--- .../use-cache-configured-timeout.test.ts | 2 +- .../use-cache-hanging.test.ts | 4 +-- .../use-cache-search-params.test.ts | 4 +-- .../child-a-tag-error.test.ts | 2 +- 14 files changed, 54 insertions(+), 34 deletions(-) diff --git a/packages/next/src/client/index.tsx b/packages/next/src/client/index.tsx index e7e44ffbef9a..e1bad70a3445 100644 --- a/packages/next/src/client/index.tsx +++ b/packages/next/src/client/index.tsx @@ -924,6 +924,14 @@ export async function hydrate(opts?: { beforeRender?: () => Promise }) { error.name = initialErr!.name error.stack = initialErr!.stack + // If present, restore the error code from the original server error. + if (typeof initialErr.__NEXT_ERROR_CODE === 'string') { + Object.defineProperty(error, '__NEXT_ERROR_CODE', { + value: initialErr.__NEXT_ERROR_CODE, + enumerable: false, + configurable: true, + }) + } const errSource = initialErr.source! // In development, error the navigation API usage in runtime, diff --git a/packages/next/src/server/dev/node-stack-frames.ts b/packages/next/src/server/dev/node-stack-frames.ts index 068526580186..a5474da067cc 100644 --- a/packages/next/src/server/dev/node-stack-frames.ts +++ b/packages/next/src/server/dev/node-stack-frames.ts @@ -43,6 +43,15 @@ export function getServerError(error: Error, type: ErrorSourceType): Error { } n.name = error.name + // If present, restore the error code from the original server error. + const errorCode = (error as any).__NEXT_ERROR_CODE + if (typeof errorCode === 'string') { + Object.defineProperty(n, '__NEXT_ERROR_CODE', { + value: errorCode, + enumerable: false, + configurable: true, + }) + } try { n.stack = `${n.toString()}\n${parse(error.stack!) .map(getFilesystemFrame) diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index 8ebfe8e9d0a3..af6f36d91564 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -103,6 +103,7 @@ import { RenderSpan } from './lib/trace/constants' import { ReflectAdapter } from './web/spec-extension/adapters/reflect' import { getCacheControlHeader } from './lib/cache-control' import { getErrorSource } from '../shared/lib/error-source' +import { extractNextErrorCode } from '../lib/error-telemetry-utils' import type { DeepReadonly } from '../shared/lib/deep-readonly' import type { PagesDevOverlayBridgeType } from '../next-devtools/userspace/pages/pages-dev-overlay-setup' import { getScriptNonceFromHeader } from './app-render/get-script-nonce-from-header' @@ -423,6 +424,7 @@ export function errorToJSON(err: Error) { message: stripAnsi(err.message), stack: err.stack, digest: (err as any).digest, + __NEXT_ERROR_CODE: extractNextErrorCode(err), } } diff --git a/packages/next/src/shared/lib/utils.ts b/packages/next/src/shared/lib/utils.ts index d5b36412bc36..109e5c90d047 100644 --- a/packages/next/src/shared/lib/utils.ts +++ b/packages/next/src/shared/lib/utils.ts @@ -99,6 +99,7 @@ export type NEXT_DATA = { err?: Error & { statusCode?: number source?: typeof COMPILER_NAMES.server | typeof COMPILER_NAMES.edgeServer + __NEXT_ERROR_CODE?: string } gsp?: boolean gssp?: boolean diff --git a/test/development/acceptance/ReactRefreshLogBox-app-doc.test.ts b/test/development/acceptance/ReactRefreshLogBox-app-doc.test.ts index 117bd50fea85..52e160815645 100644 --- a/test/development/acceptance/ReactRefreshLogBox-app-doc.test.ts +++ b/test/development/acceptance/ReactRefreshLogBox-app-doc.test.ts @@ -19,7 +19,7 @@ describe('ReactRefreshLogBox _app _document', () => { const { browser, session } = sandbox await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E464", "description": "The default export is not a React Component in page: "/_app"", "environmentLabel": null, "label": "Runtime Error", @@ -49,7 +49,7 @@ describe('ReactRefreshLogBox _app _document', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E511", "description": "The default export is not a React Component in page: "/_document"", "environmentLabel": null, "label": "Runtime Error", diff --git a/test/development/acceptance/ReactRefreshLogBox.test.ts b/test/development/acceptance/ReactRefreshLogBox.test.ts index 78c4ba190dc3..4980b1deb73a 100644 --- a/test/development/acceptance/ReactRefreshLogBox.test.ts +++ b/test/development/acceptance/ReactRefreshLogBox.test.ts @@ -1249,7 +1249,7 @@ describe('ReactRefreshLogBox', () => { if (isReact18) { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E336", "description": "A null error was thrown, see here for more info: https://nextjs.org/docs/messages/threw-undefined", "environmentLabel": null, "label": "Runtime Error", @@ -1260,7 +1260,7 @@ describe('ReactRefreshLogBox', () => { } else { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E336", "description": "A null error was thrown, see here for more info: https://nextjs.org/docs/messages/threw-undefined", "environmentLabel": null, "label": "Runtime Error", diff --git a/test/development/app-dir/server-navigation-error/server-navigation-error.test.ts b/test/development/app-dir/server-navigation-error/server-navigation-error.test.ts index 8a08e7c62f58..ef9b1ecf9802 100644 --- a/test/development/app-dir/server-navigation-error/server-navigation-error.test.ts +++ b/test/development/app-dir/server-navigation-error/server-navigation-error.test.ts @@ -30,7 +30,7 @@ describe('server-navigation-error', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E1041", "description": "Next.js navigation API is not allowed to be used in Pages Router.", "environmentLabel": null, "label": "Runtime Error", @@ -72,7 +72,7 @@ describe('server-navigation-error', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E1041", "description": "Next.js navigation API is not allowed to be used in Middleware.", "environmentLabel": null, "label": "Runtime Error", diff --git a/test/development/pages-dir/client-navigation/rendering.test.ts b/test/development/pages-dir/client-navigation/rendering.test.ts index e4bb383f4f95..f789c97e49f6 100644 --- a/test/development/pages-dir/client-navigation/rendering.test.ts +++ b/test/development/pages-dir/client-navigation/rendering.test.ts @@ -130,7 +130,7 @@ describe('Client Navigation rendering', () => { if (isReact18 && isTurbopack) { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E490", "description": "Circular structure in "getInitialProps" result of page "/circular-json-error". https://nextjs.org/docs/messages/circular-structure", "environmentLabel": null, "label": "Runtime Error", @@ -143,7 +143,7 @@ describe('Client Navigation rendering', () => { } else { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E490", "description": "Circular structure in "getInitialProps" result of page "/circular-json-error". https://nextjs.org/docs/messages/circular-structure", "environmentLabel": null, "label": "Runtime Error", @@ -162,7 +162,7 @@ describe('Client Navigation rendering', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E1035", "description": ""InstanceInitialPropsPage.getInitialProps()" is defined as an instance method - visit https://nextjs.org/docs/messages/get-initial-props-as-an-instance-method for more information.", "environmentLabel": null, "label": "Runtime Error", @@ -177,7 +177,7 @@ describe('Client Navigation rendering', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E1025", "description": ""EmptyInitialPropsPage.getInitialProps()" should resolve to an object. But found "null" instead.", "environmentLabel": null, "label": "Runtime Error", @@ -221,7 +221,7 @@ describe('Client Navigation rendering', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E286", "description": "The default export is not a React Component in page: "/no-default-export"", "environmentLabel": null, "label": "Runtime Error", @@ -458,7 +458,7 @@ describe('Client Navigation rendering', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E98", "description": "An undefined error was thrown, see here for more info: https://nextjs.org/docs/messages/threw-undefined", "environmentLabel": null, "label": "Runtime Error", diff --git a/test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts b/test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts index de806d7d986d..a25b7fe09431 100644 --- a/test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts +++ b/test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts @@ -2306,7 +2306,7 @@ describe('Cache Components Errors', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E831", "description": "Route /use-cache-cookies used \`cookies()\` inside "use cache". Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use \`cookies()\` outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache", "environmentLabel": null, "label": "Runtime Error", @@ -2415,7 +2415,7 @@ describe('Cache Components Errors', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E829", "description": "Route /use-cache-draft-mode used "draftMode().enable()" inside "use cache". The enabled status of \`draftMode()\` can be read in caches but you must not enable or disable \`draftMode()\` inside a cache. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache", "environmentLabel": null, "label": "Runtime Error", @@ -2523,7 +2523,7 @@ describe('Cache Components Errors', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E833", "description": "Route /use-cache-headers used \`headers()\` inside "use cache". Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use \`headers()\` outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache", "environmentLabel": null, "label": "Runtime Error", @@ -2630,7 +2630,7 @@ describe('Cache Components Errors', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E841", "description": "Route /use-cache-connection used \`connection()\` inside "use cache". The \`connection()\` function is used to indicate the subsequent code must only run when there is an actual request, but caches must be able to be produced before a request, so this function is not allowed in this scope. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache", "environmentLabel": null, "label": "Runtime Error", @@ -3060,7 +3060,7 @@ Learn more: https://nextjs.org/docs/messages/blocking-route` await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E1009", "description": "A "use cache" with short \`expire\` (under 5 minutes) is nested inside another "use cache" that has no explicit \`cacheLife\`, which is not allowed during prerendering. Add \`cacheLife()\` to the outer \`"use cache"\` to choose whether it should be prerendered (with longer \`expire\`) or remain dynamic (with short \`expire\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife", "environmentLabel": null, "label": "Runtime Error", @@ -3489,7 +3489,7 @@ Learn more: https://nextjs.org/docs/messages/blocking-route` await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E1000", "description": "A "use cache" with zero \`revalidate\` is nested inside another "use cache" that has no explicit \`cacheLife\`, which is not allowed during prerendering. Add \`cacheLife()\` to the outer \`"use cache"\` to choose whether it should be prerendered (with non-zero \`revalidate\`) or remain dynamic (with zero \`revalidate\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife", "environmentLabel": null, "label": "Runtime Error", @@ -3884,7 +3884,7 @@ Learn more: https://nextjs.org/docs/messages/blocking-route` await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E831", "description": "Route /use-cache-cookies-third-party used \`cookies()\` inside "use cache". Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use \`cookies()\` outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache", "environmentLabel": null, "label": "Runtime Error", @@ -3983,7 +3983,7 @@ Learn more: https://nextjs.org/docs/messages/blocking-route` await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E829", "description": "Route /use-cache-draft-mode-third-party used "draftMode().enable()" inside "use cache". The enabled status of \`draftMode()\` can be read in caches but you must not enable or disable \`draftMode()\` inside a cache. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache", "environmentLabel": null, "label": "Runtime Error", @@ -4081,7 +4081,7 @@ Learn more: https://nextjs.org/docs/messages/blocking-route` await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E833", "description": "Route /use-cache-headers-third-party used \`headers()\` inside "use cache". Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use \`headers()\` outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache", "environmentLabel": null, "label": "Runtime Error", @@ -4180,7 +4180,7 @@ Learn more: https://nextjs.org/docs/messages/blocking-route` await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E841", "description": "Route /use-cache-connection-third-party used \`connection()\` inside "use cache". The \`connection()\` function is used to indicate the subsequent code must only run when there is an actual request, but caches must be able to be produced before a request, so this function is not allowed in this scope. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache", "environmentLabel": null, "label": "Runtime Error", @@ -4283,7 +4283,7 @@ Learn more: https://nextjs.org/docs/messages/blocking-route` if (isTurbopack) { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E1016", "description": ""use cache: private" must not be used within \`unstable_cache()\`.", "environmentLabel": null, "label": "Runtime Error", @@ -4299,7 +4299,7 @@ Learn more: https://nextjs.org/docs/messages/blocking-route` } else { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E1016", "description": ""use cache: private" must not be used within \`unstable_cache()\`.", "environmentLabel": null, "label": "Runtime Error", @@ -4411,7 +4411,7 @@ Learn more: https://nextjs.org/docs/messages/blocking-route` await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E1001", "description": ""use cache: private" must not be used within "use cache". It can only be nested inside of another "use cache: private".", "environmentLabel": null, "label": "Runtime Error", diff --git a/test/e2e/app-dir/undefined-default-export/undefined-default-export.test.ts b/test/e2e/app-dir/undefined-default-export/undefined-default-export.test.ts index aacc2dc11fbd..b1312e11112d 100644 --- a/test/e2e/app-dir/undefined-default-export/undefined-default-export.test.ts +++ b/test/e2e/app-dir/undefined-default-export/undefined-default-export.test.ts @@ -14,7 +14,7 @@ describe('Undefined default export', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E45", "description": "The default export is not a React Component in "/specific-path/1/page"", "environmentLabel": null, "label": "Runtime Error", @@ -29,7 +29,7 @@ describe('Undefined default export', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E45", "description": "The default export is not a React Component in "/specific-path/2/layout"", "environmentLabel": null, "label": "Runtime Error", @@ -44,7 +44,7 @@ describe('Undefined default export', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E45", "description": "The default export is not a React Component in "/will-not-found/not-found"", "environmentLabel": null, "label": "Runtime Error", diff --git a/test/e2e/app-dir/use-cache-configured-timeout/use-cache-configured-timeout.test.ts b/test/e2e/app-dir/use-cache-configured-timeout/use-cache-configured-timeout.test.ts index 794eedff33b6..66cd685d14c8 100644 --- a/test/e2e/app-dir/use-cache-configured-timeout/use-cache-configured-timeout.test.ts +++ b/test/e2e/app-dir/use-cache-configured-timeout/use-cache-configured-timeout.test.ts @@ -38,7 +38,7 @@ describe('use-cache-configured-timeout', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E236", "description": "Filling a cache during prerender timed out, likely because request-specific arguments such as params, searchParams, cookies() or dynamic data were used inside "use cache".", "environmentLabel": null, "label": "Runtime Error", diff --git a/test/e2e/app-dir/use-cache-hanging/use-cache-hanging.test.ts b/test/e2e/app-dir/use-cache-hanging/use-cache-hanging.test.ts index 3683aa3d9695..74b811cbac35 100644 --- a/test/e2e/app-dir/use-cache-hanging/use-cache-hanging.test.ts +++ b/test/e2e/app-dir/use-cache-hanging/use-cache-hanging.test.ts @@ -23,7 +23,7 @@ describe('use-cache-hanging', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E236", "description": "Filling a cache during prerender timed out, likely because request-specific arguments such as params, searchParams, cookies() or dynamic data were used inside "use cache".", "environmentLabel": null, "label": "Runtime Error", @@ -52,7 +52,7 @@ describe('use-cache-hanging', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E236", "description": "Filling a cache during prerender timed out, likely because request-specific arguments such as params, searchParams, cookies() or dynamic data were used inside "use cache".", "environmentLabel": null, "label": "Runtime Error", diff --git a/test/e2e/app-dir/use-cache-search-params/use-cache-search-params.test.ts b/test/e2e/app-dir/use-cache-search-params/use-cache-search-params.test.ts index 02a7ac7282f3..98c3bd5ff9db 100644 --- a/test/e2e/app-dir/use-cache-search-params/use-cache-search-params.test.ts +++ b/test/e2e/app-dir/use-cache-search-params/use-cache-search-params.test.ts @@ -136,7 +136,7 @@ describe('use-cache-search-params', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E842", "description": "Route /search-params-used-generate-metadata used \`searchParams\` inside "use cache". Accessing dynamic request data inside a cache scope is not supported. If you need some search params inside a cached function await \`searchParams\` outside of the cached function and pass only the required search params as arguments to the cached function. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache", "environmentLabel": null, "label": "Runtime Error", @@ -157,7 +157,7 @@ describe('use-cache-search-params', () => { await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E842", "description": "Route /search-params-used-generate-viewport used \`searchParams\` inside "use cache". Accessing dynamic request data inside a cache scope is not supported. If you need some search params inside a cached function await \`searchParams\` outside of the cached function and pass only the required search params as arguments to the cached function. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache", "environmentLabel": null, "label": "Runtime Error", diff --git a/test/e2e/new-link-behavior/child-a-tag-error.test.ts b/test/e2e/new-link-behavior/child-a-tag-error.test.ts index 936a9603f97f..6c95172e248c 100644 --- a/test/e2e/new-link-behavior/child-a-tag-error.test.ts +++ b/test/e2e/new-link-behavior/child-a-tag-error.test.ts @@ -31,7 +31,7 @@ describe('New Link Behavior with child', () => { ) await expect(browser).toDisplayRedbox(` { - "code": "E394", + "code": "E209", "description": "Invalid with child. Please remove or use . Learn more: https://nextjs.org/docs/messages/invalid-new-link-with-extra-anchor", "environmentLabel": null,