From 7282ce6759ea2373b2060e0720abe8983e1a87ae Mon Sep 17 00:00:00 2001 From: Brooke <25040341+brookemosby@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:07:34 -0700 Subject: [PATCH 1/3] [prebuilt-skew-protection] feat: adding in automatic deploymentId (#88496) Original [PR](https://github.com/vercel/next.js/pull/88012/) has a bug that attempted to validate on environment variables. Edited function to not run validations on environment variables, only on the user provided deployment id. --------- Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> Co-authored-by: JJ Kasper --- errors/deploymentid-invalid-characters.mdx | 71 ++++ errors/deploymentid-not-a-string.mdx | 34 ++ errors/deploymentid-too-long.mdx | 51 +++ packages/next/errors.json | 6 +- .../next/src/build/adapter/build-complete.ts | 8 + packages/next/src/build/analyze/index.ts | 6 +- packages/next/src/build/build-context.ts | 2 + packages/next/src/build/define-env.ts | 4 +- .../next/src/build/generate-deployment-id.ts | 99 ++++++ .../src/build/generate-routes-manifest.ts | 3 + packages/next/src/build/index.ts | 85 ++++- packages/next/src/build/swc/index.ts | 4 + .../next/src/build/turbopack-build/impl.ts | 9 +- packages/next/src/build/webpack-config.ts | 12 +- packages/next/src/export/index.ts | 3 +- packages/next/src/lib/inline-static-env.ts | 7 +- packages/next/src/server/base-server.ts | 3 +- packages/next/src/server/config-shared.ts | 3 +- packages/next/src/server/config.ts | 18 +- .../src/server/dev/hot-reloader-turbopack.ts | 10 +- .../next/src/server/evaluate-deployment-id.ts | 29 ++ .../src/server/route-modules/route-module.ts | 7 +- .../shared/lib/turbopack/manifest-loader.ts | 9 + .../deployment-id-function.test.ts | 328 ++++++++++++++++++ .../tsconfig.json | 8 +- .../deployment-id-handling/app/next.config.js | 9 +- .../deterministic-build/deployment-id.test.ts | 19 + test/unit/generate-deployment-id.test.ts | 263 ++++++++++++++ 28 files changed, 1071 insertions(+), 39 deletions(-) create mode 100644 errors/deploymentid-invalid-characters.mdx create mode 100644 errors/deploymentid-not-a-string.mdx create mode 100644 errors/deploymentid-too-long.mdx create mode 100644 packages/next/src/build/generate-deployment-id.ts create mode 100644 packages/next/src/server/evaluate-deployment-id.ts create mode 100644 test/e2e/deployment-id-function/deployment-id-function.test.ts create mode 100644 test/unit/generate-deployment-id.test.ts diff --git a/errors/deploymentid-invalid-characters.mdx b/errors/deploymentid-invalid-characters.mdx new file mode 100644 index 0000000000000..1aa52e4cb67aa --- /dev/null +++ b/errors/deploymentid-invalid-characters.mdx @@ -0,0 +1,71 @@ +--- +title: '`deploymentId` contains invalid characters' +--- + +## Why This Error Occurred + +The `deploymentId` in your `next.config.js` contains characters that are not allowed. Only alphanumeric characters (a-z, A-Z, 0-9), hyphens (-), and underscores (\_) are permitted. + +## Possible Ways to Fix It + +### Option 1: Remove Invalid Characters + +Remove or replace any characters that are not alphanumeric, hyphens, or underscores: + +```js +// ✅ Correct +module.exports = { + deploymentId: 'my-deployment-123', // Only alphanumeric, hyphens, underscores +} + +// ❌ Incorrect +module.exports = { + deploymentId: 'my deployment 123', // Contains spaces + deploymentId: 'my.deployment.123', // Contains dots + deploymentId: 'my/deployment/123', // Contains slashes + deploymentId: 'my@deployment#123', // Contains special characters +} +``` + +### Option 2: Sanitize the Deployment ID + +If you're generating the ID from user input or other sources, sanitize it to remove invalid characters: + +```js +// next.config.js +module.exports = { + deploymentId: () => { + const rawId = process.env.DEPLOYMENT_ID || 'default-id' + // Remove all characters that are not alphanumeric, hyphens, or underscores + return rawId.replace(/[^a-zA-Z0-9_-]/g, '') + }, +} +``` + +### Option 3: Use a Valid Format + +Common valid formats include: + +```js +// next.config.js +module.exports = { + // Using hyphens + deploymentId: 'my-deployment-id', + + // Using underscores + deploymentId: 'my_deployment_id', + + // Alphanumeric only + deploymentId: 'mydeployment123', + + // Mixed format + deploymentId: 'my-deployment_123', +} +``` + +## Additional Information + +- The deployment ID is used for skew protection and asset versioning +- Invalid characters can cause issues with URL encoding and routing +- Keep the ID URL-friendly by using only the allowed character set +- The validation ensures compatibility across different systems and environments diff --git a/errors/deploymentid-not-a-string.mdx b/errors/deploymentid-not-a-string.mdx new file mode 100644 index 0000000000000..277ac409b42ff --- /dev/null +++ b/errors/deploymentid-not-a-string.mdx @@ -0,0 +1,34 @@ +--- +title: '`deploymentId` function must return a string' +--- + +## Why This Error Occurred + +The `deploymentId` option in your `next.config.js` is defined as a function, but it did not return a string value. + +## Possible Ways to Fix It + +Always return a string from your `deploymentId` function: + +```js +// ✅ Correct +module.exports = { + deploymentId: () => { + return process.env.GIT_HASH || Date.now().toString() + }, +} + +// ❌ Incorrect +module.exports = { + deploymentId: () => { + // Missing return statement or returning non-string + return null + }, +} +``` + +The `deploymentId` can be: + +- A string: `deploymentId: 'my-deployment-123'` +- A function that returns a string: `deploymentId: () => process.env.GIT_HASH || ''` +- `undefined` (will be empty string, or use `NEXT_DEPLOYMENT_ID` environment variable if set) diff --git a/errors/deploymentid-too-long.mdx b/errors/deploymentid-too-long.mdx new file mode 100644 index 0000000000000..8deb3901ea6c5 --- /dev/null +++ b/errors/deploymentid-too-long.mdx @@ -0,0 +1,51 @@ +# Deployment ID Too Long + +The `deploymentId` in your `next.config.js` exceeds the maximum length of 32 characters. + +## Why This Error Occurred + +The `deploymentId` configuration option has a maximum length of 32 characters to ensure compatibility with various systems and constraints. + +## Possible Ways to Fix It + +### Option 1: Shorten Your Deployment ID + +Reduce the length of your `deploymentId` to 32 characters or less: + +```js +// next.config.js +module.exports = { + deploymentId: 'my-short-id', // ✅ 12 characters +} +``` + +### Option 2: Use a Function to Generate a Shorter ID + +If you're generating the ID dynamically, ensure it doesn't exceed 32 characters: + +```js +// next.config.js +module.exports = { + deploymentId: () => { + // Generate a shorter ID (e.g., hash or truncate) + return process.env.GIT_COMMIT_SHA?.substring(0, 32) || 'default-id' + }, +} +``` + +### Option 3: Truncate Environment Variables + +If using environment variables, ensure the value is truncated to 32 characters: + +```js +// next.config.js +module.exports = { + deploymentId: process.env.DEPLOYMENT_ID?.substring(0, 32), +} +``` + +## Additional Information + +- The deployment ID is used for skew protection and asset versioning +- Keep it concise but meaningful for your use case +- Consider using hashes or shortened identifiers if you need unique values diff --git a/packages/next/errors.json b/packages/next/errors.json index 32c6553b43582..d56ec2ec58fc6 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -980,5 +980,9 @@ "979": "invariant: expected %s bytes of postponed state but only received %s bytes", "980": "Failed to load client middleware manifest", "981": "resolvedPathname must be set in request metadata", - "982": "`serializeResumeDataCache` should not be called in edge runtime." + "982": "`serializeResumeDataCache` should not be called in edge runtime.", + "983": "deploymentId function must return a string. https://nextjs.org/docs/messages/deploymentid-not-a-string", + "984": "The deploymentId \"%s\" cannot start with the \"dpl_\" prefix. Please choose a different deploymentId in your next.config.js. https://vercel.com/docs/skew-protection#custom-skew-protection-deployment-id", + "985": "The deploymentId \"%s\" exceeds the maximum length of 32 characters. Please choose a shorter deploymentId. https://nextjs.org/docs/messages/deploymentid-too-long", + "986": "The deploymentId \"%s\" contains invalid characters. Only alphanumeric characters (a-z, A-Z, 0-9), hyphens (-), and underscores (_) are allowed. https://nextjs.org/docs/messages/deploymentid-invalid-characters" } diff --git a/packages/next/src/build/adapter/build-complete.ts b/packages/next/src/build/adapter/build-complete.ts index 1af503c507551..173efaaab9a50 100644 --- a/packages/next/src/build/adapter/build-complete.ts +++ b/packages/next/src/build/adapter/build-complete.ts @@ -386,6 +386,11 @@ export interface NextAdapter { * influenced by NextConfig.generateBuildId */ buildId: string + /** + * deploymentId is the current deployment ID, this can be + * influenced by NextConfig.deploymentId or NEXT_DEPLOYMENT_ID environment variable + */ + deploymentId: string }) => Promise | void } @@ -415,6 +420,7 @@ export async function handleBuildComplete({ config, appType, buildId, + deploymentId, configOutDir, distDir, pageKeys, @@ -438,6 +444,7 @@ export async function handleBuildComplete({ appType: 'app' | 'pages' | 'hybrid' distDir: string buildId: string + deploymentId: string configOutDir: string adapterPath: string tracingRoot: string @@ -1856,6 +1863,7 @@ export async function handleBuildComplete({ config, distDir, buildId, + deploymentId, nextVersion, projectDir: dir, repoRoot: tracingRoot, diff --git a/packages/next/src/build/analyze/index.ts b/packages/next/src/build/analyze/index.ts index bc9b099743544..bccff24fea6b8 100644 --- a/packages/next/src/build/analyze/index.ts +++ b/packages/next/src/build/analyze/index.ts @@ -21,6 +21,7 @@ import loadCustomRoutes from '../../lib/load-custom-routes' import { generateRoutesManifest } from '../generate-routes-manifest' import { checkIsAppPPREnabled } from '../../server/lib/experimental/ppr' import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' +import { resolveAndSetDeploymentId } from '../generate-deployment-id' import http from 'node:http' // @ts-expect-error types are in @types/serve-handler @@ -53,7 +54,10 @@ export default async function analyze({ reactProductionProfiling, }) - process.env.NEXT_DEPLOYMENT_ID = config.deploymentId || '' + config.deploymentId = resolveAndSetDeploymentId( + config.deploymentId, + config.deploymentId != null ? 'user-config' : 'env-var' + ) const distDir = path.join(dir, '.next') const telemetry = new Telemetry({ distDir }) diff --git a/packages/next/src/build/build-context.ts b/packages/next/src/build/build-context.ts index 0eff06e1194cb..8710de0f7ed90 100644 --- a/packages/next/src/build/build-context.ts +++ b/packages/next/src/build/build-context.ts @@ -49,6 +49,7 @@ export interface MappedPages { // to pass it through function arguments. // Not exhaustive, but should be extended to as needed whilst refactoring export const NextBuildContext: Partial<{ + deploymentId?: string | undefined compilerIdx?: number pluginState: Record // core fields @@ -96,6 +97,7 @@ export const NextBuildContext: Partial<{ isCompileMode?: boolean debugPrerender: boolean analyze: boolean + preservedDeploymentId?: string debugBuildPaths?: { app: string[] pages: string[] diff --git a/packages/next/src/build/define-env.ts b/packages/next/src/build/define-env.ts index a2cafd4d8260b..786089eb21c42 100644 --- a/packages/next/src/build/define-env.ts +++ b/packages/next/src/build/define-env.ts @@ -46,6 +46,7 @@ const DEFINE_ENV_EXPRESSION = Symbol('DEFINE_ENV_EXPRESSION') interface DefineEnv { [key: string]: | string + | (() => string) | string[] | boolean | { [DEFINE_ENV_EXPRESSION]: string } @@ -170,7 +171,8 @@ export function getDefineEnv({ 'process.env.__NEXT_CACHE_COMPONENTS': isCacheComponentsEnabled, 'process.env.__NEXT_USE_CACHE': isUseCacheEnabled, - ...(config.experimental?.useSkewCookie || !config.deploymentId + ...(config.experimental?.useSkewCookie || + (!config.deploymentId && !config.experimental?.runtimeServerDeploymentId) ? { 'process.env.NEXT_DEPLOYMENT_ID': false, } diff --git a/packages/next/src/build/generate-deployment-id.ts b/packages/next/src/build/generate-deployment-id.ts new file mode 100644 index 0000000000000..8ba1af8577483 --- /dev/null +++ b/packages/next/src/build/generate-deployment-id.ts @@ -0,0 +1,99 @@ +/** + * Generates a deployment ID from a user-provided function or string. + * Similar to generateBuildId, but for deploymentId. + */ +export function generateDeploymentId( + deploymentId: string | (() => string) | undefined +): string | undefined { + if (typeof deploymentId === 'function') { + const result = deploymentId() + if (typeof result !== 'string') { + throw new Error( + 'deploymentId function must return a string. https://nextjs.org/docs/messages/deploymentid-not-a-string' + ) + } + return result + } + + if (typeof deploymentId === 'string') { + return deploymentId + } + + return undefined +} + +type DeploymentIdSource = 'user-config' | 'env-var' + +/** + * Resolves and sets the deployment ID from config, handling precedence and ensuring function is only evaluated once. + * User-configured deploymentId always takes precedence over NEXT_DEPLOYMENT_ID. + * + * @param configDeploymentId - The deploymentId from config (can be string, function, or undefined) + * @param source - Source indicator: 'user-config' treats as user-configured (validates), 'env-var' uses NEXT_DEPLOYMENT_ID + * @param fallbackDeploymentId - Optional fallback deployment ID to use if process.env.NEXT_DEPLOYMENT_ID is empty + * @returns The resolved deploymentId string to use + */ +export function resolveAndSetDeploymentId( + configDeploymentId: string | (() => string) | undefined, + source: DeploymentIdSource, + fallbackDeploymentId?: string +): string { + if (source === 'env-var') { + // Prefer fallbackDeploymentId (from combinedEnv) over process.env since + // loadEnvConfig may have reset process.env + let envDeploymentId = + fallbackDeploymentId || process.env['NEXT_DEPLOYMENT_ID'] || '' + + if ( + envDeploymentId && + envDeploymentId !== process.env['NEXT_DEPLOYMENT_ID'] + ) { + process.env['NEXT_DEPLOYMENT_ID'] = envDeploymentId + } + if (envDeploymentId.length > 0) { + if (envDeploymentId.length > 32) { + throw new Error( + `The deploymentId "${envDeploymentId}" exceeds the maximum length of 32 characters. Please choose a shorter deploymentId. https://nextjs.org/docs/messages/deploymentid-too-long` + ) + } + const validCharacterPattern = /^[a-zA-Z0-9_-]+$/ + if (!validCharacterPattern.test(envDeploymentId)) { + throw new Error( + `The deploymentId "${envDeploymentId}" contains invalid characters. Only alphanumeric characters (a-z, A-Z, 0-9), hyphens (-), and underscores (_) are allowed. https://nextjs.org/docs/messages/deploymentid-invalid-characters` + ) + } + process.env['NEXT_DEPLOYMENT_ID'] = envDeploymentId + return envDeploymentId + } + return '' + } + + let userConfiguredDeploymentId: string | undefined + if (typeof configDeploymentId === 'string') { + userConfiguredDeploymentId = configDeploymentId + } else if (typeof configDeploymentId === 'function') { + userConfiguredDeploymentId = generateDeploymentId(configDeploymentId) + } + + if (userConfiguredDeploymentId !== undefined) { + if (userConfiguredDeploymentId.length === 0) { + return process.env['NEXT_DEPLOYMENT_ID'] || '' + } + + if (userConfiguredDeploymentId.length > 32) { + throw new Error( + `The deploymentId "${userConfiguredDeploymentId}" exceeds the maximum length of 32 characters. Please choose a shorter deploymentId. https://nextjs.org/docs/messages/deploymentid-too-long` + ) + } + const validCharacterPattern = /^[a-zA-Z0-9_-]+$/ + if (!validCharacterPattern.test(userConfiguredDeploymentId)) { + throw new Error( + `The deploymentId "${userConfiguredDeploymentId}" contains invalid characters. Only alphanumeric characters (a-z, A-Z, 0-9), hyphens (-), and underscores (_) are allowed. https://nextjs.org/docs/messages/deploymentid-invalid-characters` + ) + } + process.env['NEXT_DEPLOYMENT_ID'] = userConfiguredDeploymentId + return userConfiguredDeploymentId + } + + return process.env['NEXT_DEPLOYMENT_ID'] || '' +} diff --git a/packages/next/src/build/generate-routes-manifest.ts b/packages/next/src/build/generate-routes-manifest.ts index 22e264f8a794d..dc7c7559bf442 100644 --- a/packages/next/src/build/generate-routes-manifest.ts +++ b/packages/next/src/build/generate-routes-manifest.ts @@ -35,6 +35,7 @@ export interface GenerateRoutesManifestOptions { restrictedRedirectPaths: string[] isAppPPREnabled: boolean appType: 'pages' | 'app' | 'hybrid' + deploymentId?: string } export interface GenerateRoutesManifestResult { @@ -60,6 +61,7 @@ export function generateRoutesManifest( restrictedRedirectPaths, isAppPPREnabled, appType, + deploymentId, } = options const sortedRoutes = sortPages([...pageKeys.pages, ...(pageKeys.app ?? [])]) @@ -134,6 +136,7 @@ export function generateRoutesManifest( queryHeader: NEXT_REWRITTEN_QUERY_HEADER, }, skipProxyUrlNormalize: config.skipProxyUrlNormalize, + deploymentId: deploymentId || undefined, ppr: isAppPPREnabled ? { chain: { diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 5346895e2687e..7681e1d28e700 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -10,7 +10,12 @@ import type { CacheControl, Revalidate } from '../server/lib/cache-control' import '../lib/setup-exception-listeners' -import { loadEnvConfig, type LoadedEnvFiles } from '@next/env' +import { + loadEnvConfig, + type LoadedEnvFiles, + initialEnv, + updateInitialEnv, +} from '@next/env' import { bold, yellow } from '../lib/picocolors' import { makeRe } from 'next/dist/compiled/picomatch' import { existsSync, promises as fs } from 'fs' @@ -128,6 +133,8 @@ import { sortByPageExts } from './sort-by-page-exts' import { getStaticInfoIncludingLayouts } from './get-static-info-including-layouts' import { PAGE_TYPES } from '../lib/page-types' import { generateBuildId } from './generate-build-id' +import { resolveAndSetDeploymentId } from './generate-deployment-id' +import { evaluateDeploymentId } from '../server/evaluate-deployment-id' import { isWriteable } from './is-writeable' import * as Log from './output/log' import createSpinner from './spinner' @@ -488,6 +495,13 @@ export type RoutesManifest = { } skipProxyUrlNormalize?: boolean caseSensitive?: boolean + /** + * User-configured deployment ID for skew protection. + * This allows users to specify a custom deployment identifier + * in their next.config.js that will be used for version skew protection + * with pre-built deployments. + */ + deploymentId?: string /** * Configuration related to Partial Prerendering. */ @@ -924,6 +938,11 @@ export default async function build( let loadedConfig: NextConfigComplete | undefined let staticWorker: StaticWorker try { + const preservedDeploymentIdAtStart = process.env.NEXT_DEPLOYMENT_ID + if (preservedDeploymentIdAtStart) { + NextBuildContext.preservedDeploymentId = preservedDeploymentIdAtStart + } + const nextBuildSpan = trace('next-build', undefined, { buildMode: experimentalBuildMode, version: process.env.__NEXT_VERSION as string, @@ -938,12 +957,35 @@ export default async function build( NextBuildContext.debugBuildPaths = debugBuildPaths await nextBuildSpan.traceAsyncFn(async () => { - // attempt to load global env values so they are available in next.config.js - const { loadedEnvFiles } = nextBuildSpan + // Check process.env first to get the latest value (e.g., from next.env in tests) + // Then fall back to NextBuildContext which might have a stale value from previous build + const preservedDeploymentId = + process.env.NEXT_DEPLOYMENT_ID || NextBuildContext.preservedDeploymentId + if (preservedDeploymentId) { + NextBuildContext.preservedDeploymentId = preservedDeploymentId + // Ensure it's in process.env so loadEnvConfig can see it + process.env.NEXT_DEPLOYMENT_ID = preservedDeploymentId + // Update initialEnv so loadEnvConfig includes it when resetting process.env + updateInitialEnv({ NEXT_DEPLOYMENT_ID: preservedDeploymentId }) + } + + const { loadedEnvFiles, combinedEnv } = nextBuildSpan .traceChild('load-dotenv') .traceFn(() => loadEnvConfig(dir, false, Log)) NextBuildContext.loadedEnvFiles = loadedEnvFiles + // Restore NEXT_DEPLOYMENT_ID after loadEnvConfig resets process.env + // Priority: preservedDeploymentId > process.env (after loadEnvConfig) > combinedEnv + if (preservedDeploymentId) { + process.env.NEXT_DEPLOYMENT_ID = preservedDeploymentId + NextBuildContext.preservedDeploymentId = preservedDeploymentId + } else if (process.env.NEXT_DEPLOYMENT_ID) { + NextBuildContext.preservedDeploymentId = process.env.NEXT_DEPLOYMENT_ID + } else if (combinedEnv.NEXT_DEPLOYMENT_ID) { + process.env.NEXT_DEPLOYMENT_ID = combinedEnv.NEXT_DEPLOYMENT_ID + NextBuildContext.preservedDeploymentId = combinedEnv.NEXT_DEPLOYMENT_ID + } + const turborepoAccessTraceResult = new TurborepoAccessTraceResult() let experimentalFeatures: ConfiguredExperimentalFeature[] = [] const config: NextConfigComplete = await nextBuildSpan @@ -967,13 +1009,32 @@ export default async function build( ) loadedConfig = config - // Reading the config can modify environment variables that influence the bundler selection. bundler = finalizeBundlerFromConfig(bundler) nextBuildSpan.setAttribute('bundler', getBundlerForTelemetry(bundler)) - // Install the native bindings early so we can have synchronous access later. await installBindings(config.experimental?.useWasmBinary) - process.env.NEXT_DEPLOYMENT_ID = config.deploymentId || '' + // Collect all possible sources of deployment ID + let currentDeploymentId = + NextBuildContext.preservedDeploymentId || + preservedDeploymentId || + process.env.NEXT_DEPLOYMENT_ID || + initialEnv?.NEXT_DEPLOYMENT_ID || + combinedEnv.NEXT_DEPLOYMENT_ID || + '' + + // Ensure process.env has the deployment ID before resolving + if (currentDeploymentId) { + process.env['NEXT_DEPLOYMENT_ID'] = currentDeploymentId + } + + config.deploymentId = resolveAndSetDeploymentId( + config.deploymentId, + config.deploymentId != null ? 'user-config' : 'env-var', + currentDeploymentId || undefined + ) + if (config.deploymentId) { + process.env['NEXT_DEPLOYMENT_ID'] = config.deploymentId + } NextBuildContext.config = config let configOutDir = 'out' @@ -993,6 +1054,7 @@ export default async function build( config ) NextBuildContext.buildId = buildId + NextBuildContext.deploymentId = config.deploymentId if (experimentalBuildMode === 'generate-env') { if (bundler === Bundler.Turbopack) { @@ -1018,7 +1080,7 @@ export default async function build( // when using compile mode static env isn't inlined so we // need to populate in normal runtime env if (isCompileMode || isGenerateMode) { - populateStaticEnv(config, config.deploymentId) + populateStaticEnv(config, config.deploymentId || '') } const customRoutes: CustomRoutes = await nextBuildSpan @@ -1637,6 +1699,7 @@ export default async function build( rewrites, restrictedRedirectPaths, isAppPPREnabled, + deploymentId: evaluateDeploymentId(config.deploymentId), }) ) @@ -2714,7 +2777,10 @@ export default async function build( 2 )};self.__MIDDLEWARE_MATCHERS_CB && self.__MIDDLEWARE_MATCHERS_CB()` - let clientMiddlewareManifestPath = config.deploymentId + const evaluatedDeploymentId = evaluateDeploymentId( + config.deploymentId + ) + let clientMiddlewareManifestPath = evaluatedDeploymentId ? path.join( CLIENT_STATIC_FILES_PATH, TURBOPACK_CLIENT_MIDDLEWARE_MANIFEST @@ -4074,7 +4140,7 @@ export default async function build( distDir, buildId, locales: config.i18n?.locales, - deploymentId: config.deploymentId, + deploymentId: evaluateDeploymentId(config.deploymentId), }) } else { await writePrerenderManifest(distDir, { @@ -4206,6 +4272,7 @@ export default async function build( config, appType, buildId, + deploymentId: (config.deploymentId as string) || '', configOutDir: path.join(dir, configOutDir), staticPages, serverPropsPages, diff --git a/packages/next/src/build/swc/index.ts b/packages/next/src/build/swc/index.ts index 257314bcb04a1..3cde6b9cabba8 100644 --- a/packages/next/src/build/swc/index.ts +++ b/packages/next/src/build/swc/index.ts @@ -854,6 +854,10 @@ function bindingToApi( ): Promise { // Avoid mutating the existing `nextConfig` object. NOTE: This is only a shallow clone. let nextConfigSerializable: Record = { ...nextConfig } + // deploymentId is already evaluated to a string in config.ts, ensure it's typed correctly + if (nextConfigSerializable.deploymentId != null) { + nextConfigSerializable.deploymentId = nextConfig.deploymentId as string + } // These values are never read by Turbopack and are potentially non-serializable. nextConfigSerializable.exportPathMap = {} diff --git a/packages/next/src/build/turbopack-build/impl.ts b/packages/next/src/build/turbopack-build/impl.ts index bc3ba062e4f56..9b09cb7e4ab0f 100644 --- a/packages/next/src/build/turbopack-build/impl.ts +++ b/packages/next/src/build/turbopack-build/impl.ts @@ -14,6 +14,8 @@ import { TurbopackManifestLoader } from '../../shared/lib/turbopack/manifest-loa import { promises as fs } from 'fs' import { PHASE_PRODUCTION_BUILD } from '../../shared/lib/constants' import loadConfig from '../../server/config' +import type { NextConfigComplete } from '../../server/config-shared' +import { evaluateDeploymentId } from '../../server/evaluate-deployment-id' import { hasCustomExportOutput } from '../../export/utils' import { Telemetry } from '../../telemetry/storage' import { setGlobal } from '../../trace' @@ -57,7 +59,9 @@ export async function turbopackBuild(): Promise<{ rootPath: config.turbopack?.root || config.outputFileTracingRoot || dir, projectPath: normalizePath(path.relative(rootPath, dir) || '.'), distDir, - nextConfig: config, + nextConfig: config as NextConfigComplete & { + deploymentId?: string + }, watch: { enable: false, }, @@ -138,7 +142,8 @@ export async function turbopackBuild(): Promise<{ distDir, encryptionKey, dev: false, - deploymentId: config.deploymentId, + deploymentId: evaluateDeploymentId(config.deploymentId), + runtimeServerDeploymentId: config.experimental.runtimeServerDeploymentId, }) const currentEntrypoints = await rawEntrypointsToEntrypoints( diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 6be28f15f2c6e..2f371e72319a9 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -32,6 +32,7 @@ import { import type { CompilerNameValues } from '../shared/lib/constants' import { execOnce } from '../shared/lib/utils' import type { NextConfigComplete } from '../server/config-shared' +import { evaluateDeploymentId } from '../server/evaluate-deployment-id' import { finalizeEntrypoint } from './entries' import * as Log from './output/log' import { buildConfiguration } from './webpack/config' @@ -563,7 +564,9 @@ export default async function getBaseWebpackConfig( pagesDir, appDir, hasReactRefresh: dev && isClient, - nextConfig: config, + nextConfig: config as NextConfigComplete & { + deploymentId?: string + }, jsConfig, transpilePackages: finalTranspilePackages, supportedBrowsers, @@ -2110,13 +2113,18 @@ export default async function getBaseWebpackConfig( __NEXT_PREVIEW_MODE_SIGNING_KEY: previewProps.previewModeSigningKey, __NEXT_PREVIEW_MODE_ENCRYPTION_KEY: previewProps.previewModeEncryptionKey, + ...(config.experimental.runtimeServerDeploymentId + ? { + NEXT_DEPLOYMENT_ID: process.env.NEXT_DEPLOYMENT_ID, + } + : {}), }, }), isClient && new BuildManifestPlugin({ buildId, dev, - deploymentId: config.deploymentId, + deploymentId: evaluateDeploymentId(config.deploymentId), rewrites, isDevFallback, appDirEnabled: hasAppDir, diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index 50ab3db0c5363..1ffd15f698af6 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -53,6 +53,7 @@ import { Telemetry } from '../telemetry/storage' import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path' import { denormalizePagePath } from '../shared/lib/page-path/denormalize-page-path' import { loadEnvConfig } from '@next/env' +import { evaluateDeploymentId } from '../server/evaluate-deployment-id' import { isAPIRoute } from '../lib/is-api-route' import { getPagePath } from '../server/require' import type { Span } from '../trace' @@ -712,7 +713,7 @@ async function exportAppImpl( batches.map(async (batch) => worker.exportPages({ buildId, - deploymentId: nextConfig.deploymentId, + deploymentId: evaluateDeploymentId(nextConfig.deploymentId) || '', exportPaths: batch, parentSpanId: span.getId(), pagesDataDir, diff --git a/packages/next/src/lib/inline-static-env.ts b/packages/next/src/lib/inline-static-env.ts index 057113326d2d8..cf4d25c6d65fe 100644 --- a/packages/next/src/lib/inline-static-env.ts +++ b/packages/next/src/lib/inline-static-env.ts @@ -6,6 +6,7 @@ import globOriginal from 'next/dist/compiled/glob' import { Sema } from 'next/dist/compiled/async-sema' import type { NextConfigComplete } from '../server/config-shared' import { getNextConfigEnv, getStaticEnv } from './static-env' +import { evaluateDeploymentId } from '../server/evaluate-deployment-id' const glob = promisify(globOriginal) @@ -17,7 +18,11 @@ export async function inlineStaticEnv({ config: NextConfigComplete }) { const nextConfigEnv = getNextConfigEnv(config) - const staticEnv = getStaticEnv(config, config.deploymentId) + // User-configured deploymentId takes precedence over NEXT_DEPLOYMENT_ID + const deploymentId = evaluateDeploymentId( + config.deploymentId || process.env.NEXT_DEPLOYMENT_ID + ) + const staticEnv = getStaticEnv(config, deploymentId) const serverDir = path.join(distDir, 'server') const serverChunks = await glob('**/*.{js,json,js.map}', { diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index e9f4768126ccf..12105f7ce36a0 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -182,6 +182,7 @@ export type RouteHandler< * the rewrites normalized to the object shape that the router expects. */ export type NormalizedRouteManifest = { + readonly deploymentId?: string | undefined readonly dynamicRoutes: ReadonlyArray readonly rewrites: { readonly beforeFiles: ReadonlyArray @@ -465,7 +466,7 @@ export default abstract class Server< } else { let id = this.nextConfig.experimental.useSkewCookie ? '' - : this.nextConfig.deploymentId || '' + : (this.nextConfig.deploymentId as string) || '' this.deploymentId = id process.env.NEXT_DEPLOYMENT_ID = id diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index f379b71275bb2..58af5a3198649 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -1149,8 +1149,9 @@ export interface NextConfig { /** * A unique identifier for a deployment that will be included in each request's query string or header. + * Can be a string or a function that returns a string. */ - deploymentId?: string + deploymentId?: string | (() => string) /** * Deploy a Next.js application under a sub-path of a domain diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index 8fe9a9cf6d188..3e59370c6c460 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -45,6 +45,7 @@ import { djb2Hash } from '../shared/lib/hash' import type { NextAdapter } from '../build/adapter/build-complete' import { HardDeprecatedConfigError } from '../shared/lib/errors/hard-deprecated-config-error' import { NextInstanceErrorState } from './mcp/tools/next-instance-error-state' +import { evaluateDeploymentId } from './evaluate-deployment-id' export { normalizeConfig } from './config-shared' export type { DomainLocale, NextConfig } from './config-shared' @@ -955,28 +956,19 @@ function assignDefaultsAndValidate( } } + if (result.deploymentId != null) { + result.deploymentId = evaluateDeploymentId(result.deploymentId) + } + if ( result.experimental.runtimeServerDeploymentId == null && phase === PHASE_PRODUCTION_BUILD && ciEnvironment.hasNextSupport && process.env.NEXT_DEPLOYMENT_ID ) { - if ( - result.deploymentId != null && - result.deploymentId !== process.env.NEXT_DEPLOYMENT_ID - ) { - throw new Error( - `The NEXT_DEPLOYMENT_ID environment variable value "${process.env.NEXT_DEPLOYMENT_ID}" does not match the provided deploymentId "${result.deploymentId}" in the config.` - ) - } result.experimental.runtimeServerDeploymentId = true } - // only leverage deploymentId - if (process.env.NEXT_DEPLOYMENT_ID) { - result.deploymentId = process.env.NEXT_DEPLOYMENT_ID - } - const tracingRoot = result?.outputFileTracingRoot const turbopackRoot = result?.turbopack?.root diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index e4846cb83ba7f..8bea4d0974773 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -77,6 +77,8 @@ import { } from './messages' import { generateEncryptionKeyBase64 } from '../app-render/encryption-utils-server' import { isAppPageRouteDefinition } from '../route-definitions/app-page-route-definition' +import type { NextConfigComplete } from '../config-shared' +import { evaluateDeploymentId } from '../evaluate-deployment-id' import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' import type { ModernSourceMapPayload } from '../lib/source-maps' import { isMetadataRouteFile } from '../../lib/metadata/is-metadata-route' @@ -254,7 +256,9 @@ export async function createHotReloaderTurbopack( rootPath, projectPath: normalizePath(relative(rootPath, projectPath) || '.'), distDir, - nextConfig: opts.nextConfig, + nextConfig: opts.nextConfig as NextConfigComplete & { + deploymentId?: string + }, watch: { enable: dev, pollIntervalMs: nextConfig.watchOptions?.pollIntervalMs, @@ -324,7 +328,9 @@ export async function createHotReloaderTurbopack( distDir, encryptionKey, dev: true, - deploymentId: nextConfig.deploymentId, + deploymentId: evaluateDeploymentId(nextConfig.deploymentId), + runtimeServerDeploymentId: + nextConfig.experimental.runtimeServerDeploymentId, }) // Dev specific diff --git a/packages/next/src/server/evaluate-deployment-id.ts b/packages/next/src/server/evaluate-deployment-id.ts new file mode 100644 index 0000000000000..a9b053ce82a6c --- /dev/null +++ b/packages/next/src/server/evaluate-deployment-id.ts @@ -0,0 +1,29 @@ +/** + * Evaluates a deployment ID from a user-provided function or string. + * Returns the string value, calling the function if needed. + * Returns empty string if undefined (for runtime use where a string is always needed). + * Handles all possible input types at runtime, including broader Function types. + * + * This file is safe to use in edge runtime - it does NOT modify process.env. + */ +export function evaluateDeploymentId( + deploymentId: string | (() => string) | Function | undefined | null | unknown +): string { + // Handle function type (including broader Function type, not just () => string) + if (typeof deploymentId === 'function') { + const result = deploymentId() + if (typeof result !== 'string') { + throw new Error( + 'deploymentId function must return a string. https://nextjs.org/docs/messages/deploymentid-not-a-string' + ) + } + return result + } + + if (typeof deploymentId === 'string') { + return deploymentId + } + + // Handle null, undefined, or any other type + return '' +} diff --git a/packages/next/src/server/route-modules/route-module.ts b/packages/next/src/server/route-modules/route-module.ts index 308f3c3118329..c74e617b1f324 100644 --- a/packages/next/src/server/route-modules/route-module.ts +++ b/packages/next/src/server/route-modules/route-module.ts @@ -57,6 +57,7 @@ import type { BaseNextRequest } from '../base-http' import type { I18NConfig, NextConfigRuntime } from '../config-shared' import ResponseCache, { type ResponseGenerator } from '../response-cache' import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' +import { evaluateDeploymentId } from '../evaluate-deployment-id' import { RouterServerContextSymbol, routerServerGlobal, @@ -544,7 +545,8 @@ export abstract class RouteModule< } deploymentId = process.env.NEXT_DEPLOYMENT_ID } else { - deploymentId = nextConfig.deploymentId || '' + // evaluateDeploymentId handles string, function, and undefined cases + deploymentId = evaluateDeploymentId(nextConfig.deploymentId) } return { nextConfig, deploymentId } @@ -946,7 +948,8 @@ export abstract class RouteModule< } deploymentId = process.env.NEXT_DEPLOYMENT_ID } else { - deploymentId = nextConfig.deploymentId || '' + // evaluateDeploymentId handles string, function, and undefined cases + deploymentId = evaluateDeploymentId(nextConfig.deploymentId) } return { diff --git a/packages/next/src/shared/lib/turbopack/manifest-loader.ts b/packages/next/src/shared/lib/turbopack/manifest-loader.ts index 5b1a13e4884da..37200a5926564 100644 --- a/packages/next/src/shared/lib/turbopack/manifest-loader.ts +++ b/packages/next/src/shared/lib/turbopack/manifest-loader.ts @@ -204,6 +204,7 @@ export class TurbopackManifestLoader { private readonly buildId: string private readonly deploymentId: string private readonly dev: boolean + private readonly runtimeServerDeploymentId: boolean constructor({ distDir, @@ -211,18 +212,21 @@ export class TurbopackManifestLoader { encryptionKey, dev, deploymentId, + runtimeServerDeploymentId = false, }: { buildId: string distDir: string encryptionKey: string dev: boolean deploymentId: string + runtimeServerDeploymentId?: boolean }) { this.distDir = distDir this.buildId = buildId this.encryptionKey = encryptionKey this.dev = dev this.deploymentId = deploymentId + this.runtimeServerDeploymentId = runtimeServerDeploymentId } delete(key: EntryKey) { @@ -763,9 +767,14 @@ export class TurbopackManifestLoader { const updateFunctionDefinition = ( fun: EdgeFunctionDefinition ): EdgeFunctionDefinition => { + const env = { ...fun.env } + if (process.env.NEXT_DEPLOYMENT_ID) { + env.NEXT_DEPLOYMENT_ID = process.env.NEXT_DEPLOYMENT_ID + } return { ...fun, files: [...(instrumentation?.files ?? []), ...fun.files], + env, } } for (const key of Object.keys(manifest.middleware)) { diff --git a/test/e2e/deployment-id-function/deployment-id-function.test.ts b/test/e2e/deployment-id-function/deployment-id-function.test.ts new file mode 100644 index 0000000000000..dbf4c1501fd9b --- /dev/null +++ b/test/e2e/deployment-id-function/deployment-id-function.test.ts @@ -0,0 +1,328 @@ +import { createNext, isNextDev } from 'e2e-utils' +import { NextInstance } from 'e2e-utils' + +describe('deploymentId function support', () => { + let next: NextInstance | undefined + // Generate unique deployment IDs for each test run to avoid Vercel conflicts + // Use a short unique ID to stay within 32 character limit + const uniqueId = Date.now().toString(36).slice(-6) + + afterEach(async () => { + if (next) { + await next.destroy() + next = undefined + } + }) + + it('should work with deploymentId as a string', async () => { + next = await createNext({ + files: { + 'app/layout.jsx': ` + export default function Layout({ children }) { + return ( + + {children} + + ) + } + `, + 'app/page.jsx': ` + export default function Page() { + return

hello world

+ } + `, + 'next.config.js': ` + module.exports = { + deploymentId: 'my-static-deployment-id-${uniqueId}' + } + `, + }, + dependencies: {}, + }) + + const res = await next.fetch('/') + const html = await res.text() + expect(html).toContain('hello world') + }) + + it('should work with deploymentId as a function returning string', async () => { + next = await createNext({ + files: { + 'app/layout.jsx': ` + export default function Layout({ children }) { + return ( + + {children} + + ) + } + `, + 'app/page.jsx': ` + export default function Page() { + return

hello world

+ } + `, + 'next.config.js': ` + module.exports = { + deploymentId: () => { + return 'my-function-deployment-id-${uniqueId}' + } + } + `, + }, + dependencies: {}, + }) + + const res = await next.fetch('/') + const html = await res.text() + expect(html).toContain('hello world') + }) + + it('should work with deploymentId function using environment variable', async () => { + next = await createNext({ + files: { + 'app/layout.jsx': ` + export default function Layout({ children }) { + return ( + + {children} + + ) + } + `, + 'app/page.jsx': ` + export default function Page() { + return

hello world

+ } + `, + 'next.config.js': ` + module.exports = { + deploymentId: () => { + return process.env.CUSTOM_DEPLOYMENT_ID || 'fallback-id' + } + } + `, + }, + env: { + CUSTOM_DEPLOYMENT_ID: `env-deployment-id-${uniqueId}`, + }, + dependencies: {}, + }) + + const res = await next.fetch('/') + const html = await res.text() + expect(html).toContain('hello world') + }) + + it('should work with useSkewCookie and deploymentId function', async () => { + next = await createNext({ + files: { + 'app/layout.jsx': ` + export default function Layout({ children }) { + return ( + + {children} + + ) + } + `, + 'app/page.jsx': ` + export default function Page() { + return

hello world

+ } + `, + 'next.config.js': ` + module.exports = { + experimental: { + useSkewCookie: true + }, + deploymentId: () => { + return 'skew-cookie-deployment-id-${uniqueId}' + } + } + `, + }, + dependencies: {}, + }) + + const res = await next.fetch('/') + const setCookieHeader = res.headers.get('set-cookie') + + // In deploy mode (NEXT_DEPLOYMENT_ID set by Vercel), expect the Vercel deployment ID (starts with dpl_) + // In prebuild mode (NEXT_DEPLOYMENT_ID not set), expect the user-configured ID + if (setCookieHeader?.includes('__vdpl=dpl_')) { + // Deploy mode: expect Vercel's deployment ID (format: dpl_...) + expect(setCookieHeader).toMatch(/__vdpl=dpl_[^;]+/) + } else { + // Prebuild mode: expect user-configured deployment ID + expect(setCookieHeader).toContain( + `__vdpl=skew-cookie-deployment-id-${uniqueId}` + ) + } + }) + + // Note: In dev mode, config validation errors are thrown after the server says "Ready", + // so createNext() resolves before the error is caught. These tests only work in + // start/deploy modes where build-time validation catches the error. + it('should throw error when deploymentId function returns non-string', async () => { + if (isNextDev) { + // Skip in dev mode - validation errors occur after server starts + return + } + + let errorThrown = false + let nextInstance: NextInstance | undefined + try { + nextInstance = await createNext({ + files: { + 'app/layout.jsx': ` + export default function Layout({ children }) { + return ( + + {children} + + ) + } + `, + 'app/page.jsx': ` + export default function Page() { + return

hello world

+ } + `, + 'next.config.js': ` + module.exports = { + deploymentId: () => { + return null + } + } + `, + }, + dependencies: {}, + }) + } catch (err: any) { + errorThrown = true + // The error is thrown in the child process, so we just verify that createNext fails + // The actual error message "deploymentId function must return a string" is visible + // in the console output but wrapped differently in different modes: + // - Start mode: "next build failed with code/signal 1" + // - Deploy mode: "Failed to deploy project" + expect(err).toBeDefined() + expect( + err.message.includes('build failed') || + err.message.includes('Failed to deploy') + ).toBe(true) + } finally { + if (nextInstance) { + await nextInstance.destroy() + } + } + // Ensure an error was actually thrown + expect(errorThrown).toBe(true) + }) + + it('should throw error when deploymentId exceeds 32 characters', async () => { + if (isNextDev) { + // Skip in dev mode - validation errors occur after server starts + return + } + + let errorThrown = false + let nextInstance: NextInstance | undefined + try { + nextInstance = await createNext({ + files: { + 'app/layout.jsx': ` + export default function Layout({ children }) { + return ( + + {children} + + ) + } + `, + 'app/page.jsx': ` + export default function Page() { + return

hello world

+ } + `, + 'next.config.js': ` + module.exports = { + deploymentId: 'this-is-a-very-long-deployment-id-that-exceeds-32-characters' + } + `, + }, + dependencies: {}, + }) + } catch (err: any) { + errorThrown = true + // The error is thrown in the child process, so we just verify that createNext fails + // The actual error message about exceeding 32 characters is visible in the console output + // but wrapped differently in different modes + expect(err).toBeDefined() + expect( + err.message.includes('build failed') || + err.message.includes('Failed to deploy') + ).toBe(true) + } finally { + if (nextInstance) { + await nextInstance.destroy() + } + } + // Ensure an error was actually thrown + expect(errorThrown).toBe(true) + }) + + it('should throw error when deploymentId function returns string exceeding 32 characters', async () => { + if (isNextDev) { + // Skip in dev mode - validation errors occur after server starts + return + } + + let errorThrown = false + let nextInstance: NextInstance | undefined + try { + nextInstance = await createNext({ + files: { + 'app/layout.jsx': ` + export default function Layout({ children }) { + return ( + + {children} + + ) + } + `, + 'app/page.jsx': ` + export default function Page() { + return

hello world

+ } + `, + 'next.config.js': ` + module.exports = { + deploymentId: () => { + return 'this-is-a-very-long-deployment-id-that-exceeds-32-characters' + } + } + `, + }, + dependencies: {}, + }) + } catch (err: any) { + errorThrown = true + // The error is thrown in the child process, so we just verify that createNext fails + // The actual error message about exceeding 32 characters is visible in the console output + // but wrapped differently in different modes + expect(err).toBeDefined() + expect( + err.message.includes('build failed') || + err.message.includes('Failed to deploy') + ).toBe(true) + } finally { + if (nextInstance) { + await nextInstance.destroy() + } + } + // Ensure an error was actually thrown + expect(errorThrown).toBe(true) + }) +}) diff --git a/test/integration/next-dynamic-css-asset-prefix/tsconfig.json b/test/integration/next-dynamic-css-asset-prefix/tsconfig.json index ce8701fb2649f..d6bef2a5f9211 100644 --- a/test/integration/next-dynamic-css-asset-prefix/tsconfig.json +++ b/test/integration/next-dynamic-css-asset-prefix/tsconfig.json @@ -20,6 +20,12 @@ ], "strictNullChecks": true }, - "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + "**/*.ts", + "**/*.tsx", + ".next/dev/types/**/*.ts" + ], "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"] } diff --git a/test/production/deployment-id-handling/app/next.config.js b/test/production/deployment-id-handling/app/next.config.js index 847dc115c5399..c06352d2623f7 100644 --- a/test/production/deployment-id-handling/app/next.config.js +++ b/test/production/deployment-id-handling/app/next.config.js @@ -1,6 +1,13 @@ /** @type {import('next').NextConfig} */ module.exports = { - deploymentId: process.env.CUSTOM_DEPLOYMENT_ID, + // Read deploymentId from env vars - CUSTOM_DEPLOYMENT_ID takes precedence for explicit tests + // If CUSTOM_DEPLOYMENT_ID is not set, fall back to NEXT_DEPLOYMENT_ID + // This ensures NEXT_DEPLOYMENT_ID is available when next.config.js is loaded, + // which happens before loadEnvConfig might reset process.env + deploymentId: + process.env.CUSTOM_DEPLOYMENT_ID || + process.env.NEXT_DEPLOYMENT_ID || + undefined, experimental: { useSkewCookie: Boolean(process.env.COOKIE_SKEW), runtimeServerDeploymentId: !!process.env.RUNTIME_SERVER_DEPLOYMENT_ID, diff --git a/test/production/deterministic-build/deployment-id.test.ts b/test/production/deterministic-build/deployment-id.test.ts index 721a599b93560..c64d7c06ef2b4 100644 --- a/test/production/deterministic-build/deployment-id.test.ts +++ b/test/production/deterministic-build/deployment-id.test.ts @@ -27,6 +27,18 @@ async function readFiles(next: NextInstance) { ) } +// TODO static/* browser chunks are content hashed and have the deployment id inlined +const IGNORE_NAME = new RegExp( + [ + 'static/chunks/', + '.*_buildManifest\\.js', + '.*_ssgManifest\\.js', + '.*_clientMiddlewareManifest\\.js', + ] + .map((v) => '(?:\\/|^)' + v + '$') + .join('|') +) + const IGNORE_CONTENT = new RegExp( [ // TODO this contains "env": { "__NEXT_BUILD_ID": "taBOOu8Znzobe4G7wEG_i", @@ -41,6 +53,10 @@ const IGNORE_CONTENT = new RegExp( // These are not critical, as they aren't deployed to the serverless function itself 'client-build-manifest\\.json', 'fallback-build-manifest\\.json', + // TODO These contain manifest file paths that include build IDs + 'build-manifest\\.json', + 'middleware-build-manifest\\.js', + // Contains the deploymentId which is expected to change between deployments 'routes-manifest\\.json', ] .map((v) => '(?:\\/|^)' + v + '$') @@ -86,6 +102,9 @@ const IGNORE_CONTENT = new RegExp( await next.build() let run2 = await readFiles(next) + run1 = run1.filter(([f, _]) => !IGNORE_NAME.test(f)) + run2 = run2.filter(([f, _]) => !IGNORE_NAME.test(f)) + // First, compare file names let run1FileNames = run1.map(([f, _]) => f) let run2FileNames = run2.map(([f, _]) => f) diff --git a/test/unit/generate-deployment-id.test.ts b/test/unit/generate-deployment-id.test.ts new file mode 100644 index 0000000000000..13947eaa18260 --- /dev/null +++ b/test/unit/generate-deployment-id.test.ts @@ -0,0 +1,263 @@ +import { generateDeploymentId } from 'next/dist/build/generate-deployment-id' +import { resolveAndSetDeploymentId } from 'next/dist/build/generate-deployment-id' + +describe('generateDeploymentId', () => { + it('should return undefined when deploymentId is undefined', () => { + expect(generateDeploymentId(undefined)).toBeUndefined() + }) + + it('should return string when deploymentId is a string', () => { + expect(generateDeploymentId('my-deployment-123')).toBe('my-deployment-123') + expect(generateDeploymentId(' my-deployment-123 ')).toBe( + ' my-deployment-123 ' + ) + }) + + it('should call function and return string when deploymentId is a function', () => { + const fn = () => 'my-deployment-123' + expect(generateDeploymentId(fn)).toBe('my-deployment-123') + + const fnWithWhitespace = () => ' my-deployment-123 ' + expect(generateDeploymentId(fnWithWhitespace)).toBe(' my-deployment-123 ') + }) + + it('should throw error when function returns non-string', () => { + const fn = () => 123 as any + expect(() => generateDeploymentId(fn)).toThrow( + 'deploymentId function must return a string' + ) + }) + + it('should handle function that returns empty string', () => { + const fn = () => '' + expect(generateDeploymentId(fn)).toBe('') + }) + + it('should handle empty string deploymentId', () => { + expect(generateDeploymentId('')).toBe('') + expect(generateDeploymentId(' ')).toBe(' ') + }) +}) + +describe('resolveAndSetDeploymentId', () => { + const originalEnv = process.env.NEXT_DEPLOYMENT_ID + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.NEXT_DEPLOYMENT_ID + } else { + process.env.NEXT_DEPLOYMENT_ID = originalEnv + } + }) + + describe('Precedence: user-configured vs NEXT_DEPLOYMENT_ID', () => { + beforeEach(() => { + delete process.env.NEXT_DEPLOYMENT_ID + }) + + it('should use user-configured deployment ID when both are provided', () => { + const userDeploymentId = 'my-custom-id' + const vercelDeploymentId = 'dpl_abc123xyz' + + process.env.NEXT_DEPLOYMENT_ID = vercelDeploymentId + + const result = resolveAndSetDeploymentId(userDeploymentId, 'user-config') + expect(result).toBe(userDeploymentId) + expect(process.env.NEXT_DEPLOYMENT_ID).toBe(userDeploymentId) + }) + + it('should use NEXT_DEPLOYMENT_ID when user config is not provided', () => { + const vercelDeploymentId = 'dpl_abc123xyz' + process.env.NEXT_DEPLOYMENT_ID = vercelDeploymentId + + const result = resolveAndSetDeploymentId(undefined, 'env-var') + expect(result).toBe(vercelDeploymentId) + expect(process.env.NEXT_DEPLOYMENT_ID).toBe(vercelDeploymentId) + }) + + it('should use user-configured function deployment ID over NEXT_DEPLOYMENT_ID', () => { + const userDeploymentId = 'my-function-id' + const vercelDeploymentId = 'dpl_abc123xyz' + + process.env.NEXT_DEPLOYMENT_ID = vercelDeploymentId + + const fn = () => userDeploymentId + const result = resolveAndSetDeploymentId(fn, 'user-config') + expect(result).toBe(userDeploymentId) + expect(process.env.NEXT_DEPLOYMENT_ID).toBe(userDeploymentId) + }) + + it('should not error when called twice with Vercel deployment ID from env var', () => { + const vercelDeploymentId = 'dpl_abc123xyz' + process.env.NEXT_DEPLOYMENT_ID = vercelDeploymentId + + const firstResult = resolveAndSetDeploymentId(undefined, 'env-var') + expect(firstResult).toBe(vercelDeploymentId) + + const secondResult = resolveAndSetDeploymentId(firstResult, 'env-var') + expect(secondResult).toBe(vercelDeploymentId) + }) + + it('should respect explicit source parameter (env-var)', () => { + const vercelDeploymentId = 'dpl_abc123xyz' + process.env.NEXT_DEPLOYMENT_ID = vercelDeploymentId + + // Explicitly mark as env-var sourced - should not validate + const result = resolveAndSetDeploymentId(vercelDeploymentId, 'env-var') + expect(result).toBe(vercelDeploymentId) + // Should not throw validation error + }) + }) + + describe('Edge cases: undefined, null, and empty values', () => { + beforeEach(() => { + delete process.env.NEXT_DEPLOYMENT_ID + }) + + it('should return empty string when NEXT_DEPLOYMENT_ID is undefined and source is env-var', () => { + delete process.env.NEXT_DEPLOYMENT_ID + const result = resolveAndSetDeploymentId(undefined, 'env-var') + expect(result).toBe('') + }) + + it('should return empty string when NEXT_DEPLOYMENT_ID is empty string and source is env-var', () => { + process.env.NEXT_DEPLOYMENT_ID = '' + const result = resolveAndSetDeploymentId(undefined, 'env-var') + expect(result).toBe('') + }) + + it('should handle empty string user-configured deployment ID (treated as not configured)', () => { + delete process.env.NEXT_DEPLOYMENT_ID + const result = resolveAndSetDeploymentId('', 'user-config') + expect(result).toBe('') + expect(process.env.NEXT_DEPLOYMENT_ID).toBeUndefined() + }) + + it('should handle function returning empty string for user-configured deployment ID (treated as not configured)', () => { + process.env.NEXT_DEPLOYMENT_ID = 'env-var-id' + const fn = () => '' + const result = resolveAndSetDeploymentId(fn, 'user-config') + expect(result).toBe('env-var-id') + expect(process.env.NEXT_DEPLOYMENT_ID).toBe('env-var-id') + }) + + it('should fall back to env var when user-config source but configDeploymentId is undefined', () => { + process.env.NEXT_DEPLOYMENT_ID = 'env-var-id' + const result = resolveAndSetDeploymentId(undefined, 'user-config') + expect(result).toBe('env-var-id') + }) + + it('should return empty string when both user-config and env var are undefined', () => { + delete process.env.NEXT_DEPLOYMENT_ID + const result = resolveAndSetDeploymentId(undefined, 'user-config') + expect(result).toBe('') + }) + + it('should handle user-configured empty string (treated as not configured, falls back to env var)', () => { + process.env.NEXT_DEPLOYMENT_ID = 'env-var-id' + const result = resolveAndSetDeploymentId('', 'user-config') + expect(result).toBe('env-var-id') + expect(process.env.NEXT_DEPLOYMENT_ID).toBe('env-var-id') + }) + + it('should reject whitespace-only user-configured deployment ID (contains invalid characters)', () => { + expect(() => resolveAndSetDeploymentId(' ', 'user-config')).toThrow( + 'contains invalid characters' + ) + }) + }) + + describe('Character validation', () => { + beforeEach(() => { + delete process.env.NEXT_DEPLOYMENT_ID + }) + + it('should reject deploymentId with invalid characters (spaces)', () => { + expect(() => + resolveAndSetDeploymentId('my deployment id', 'user-config') + ).toThrow('contains invalid characters') + }) + + it('should reject deploymentId with invalid characters (question mark)', () => { + expect(() => + resolveAndSetDeploymentId('my-deployment?id=123', 'user-config') + ).toThrow('contains invalid characters') + }) + + it('should reject deploymentId with invalid characters (ampersand)', () => { + expect(() => + resolveAndSetDeploymentId('my-deployment&id=123', 'user-config') + ).toThrow('contains invalid characters') + }) + + it('should reject deploymentId with invalid characters (percent)', () => { + expect(() => + resolveAndSetDeploymentId('my-deployment%20id', 'user-config') + ).toThrow('contains invalid characters') + }) + + it('should reject deploymentId with invalid characters (slash)', () => { + expect(() => + resolveAndSetDeploymentId('my/deployment/id', 'user-config') + ).toThrow('contains invalid characters') + }) + + it('should reject deploymentId with invalid characters (dot)', () => { + expect(() => + resolveAndSetDeploymentId('my.deployment.id', 'user-config') + ).toThrow('contains invalid characters') + }) + + it('should reject deploymentId with control characters', () => { + expect(() => + resolveAndSetDeploymentId('my-deployment\tid', 'user-config') + ).toThrow('contains invalid characters') + }) + + it('should allow deploymentId with valid characters (base62 + hyphen + underscore)', () => { + const result = resolveAndSetDeploymentId( + 'my-deployment_v2-abc123XYZ', + 'user-config' + ) + expect(result).toBe('my-deployment_v2-abc123XYZ') + expect(process.env.NEXT_DEPLOYMENT_ID).toBe('my-deployment_v2-abc123XYZ') + }) + + it('should allow deploymentId with only alphanumeric characters', () => { + const result = resolveAndSetDeploymentId('abc123XYZ789', 'user-config') + expect(result).toBe('abc123XYZ789') + expect(process.env.NEXT_DEPLOYMENT_ID).toBe('abc123XYZ789') + }) + + it('should allow deploymentId with only hyphens', () => { + const result = resolveAndSetDeploymentId('---', 'user-config') + expect(result).toBe('---') + expect(process.env.NEXT_DEPLOYMENT_ID).toBe('---') + }) + + it('should allow deploymentId with only underscores', () => { + const result = resolveAndSetDeploymentId('___', 'user-config') + expect(result).toBe('___') + expect(process.env.NEXT_DEPLOYMENT_ID).toBe('___') + }) + + it('should reject deploymentId from function that returns invalid characters', () => { + const fn = () => 'my deployment id' + expect(() => resolveAndSetDeploymentId(fn, 'user-config')).toThrow( + 'contains invalid characters' + ) + }) + + it('should allow deploymentId from function that returns valid characters', () => { + const fn = () => 'my-deployment_v2-abc123XYZ' + const result = resolveAndSetDeploymentId(fn, 'user-config') + expect(result).toBe('my-deployment_v2-abc123XYZ') + expect(process.env.NEXT_DEPLOYMENT_ID).toBe('my-deployment_v2-abc123XYZ') + }) + + it('should allow empty string (treated as not configured)', () => { + const result = resolveAndSetDeploymentId('', 'user-config') + expect(result).toBe('') + }) + }) +}) From af10864a106068160307338ce6946051f16d8273 Mon Sep 17 00:00:00 2001 From: nextjs-bot Date: Thu, 22 Jan 2026 21:42:11 +0000 Subject: [PATCH 2/3] v16.2.0-canary.4 --- lerna.json | 2 +- packages/create-next-app/package.json | 2 +- packages/eslint-config-next/package.json | 4 ++-- packages/eslint-plugin-internal/package.json | 2 +- packages/eslint-plugin-next/package.json | 2 +- packages/font/package.json | 2 +- packages/next-bundle-analyzer/package.json | 2 +- packages/next-codemod/package.json | 2 +- packages/next-env/package.json | 2 +- packages/next-mdx/package.json | 2 +- packages/next-plugin-storybook/package.json | 2 +- packages/next-polyfill-module/package.json | 2 +- packages/next-polyfill-nomodule/package.json | 2 +- packages/next-routing/package.json | 2 +- packages/next-rspack/package.json | 2 +- packages/next-swc/package.json | 2 +- packages/next/package.json | 14 +++++++------- packages/react-refresh-utils/package.json | 2 +- packages/third-parties/package.json | 4 ++-- pnpm-lock.yaml | 16 ++++++++-------- 20 files changed, 35 insertions(+), 35 deletions(-) diff --git a/lerna.json b/lerna.json index 6da3544572b4d..777f3a4a13480 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.2.0-canary.3" + "version": "16.2.0-canary.4" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index e5213e7674385..58fef370a5a8c 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.2.0-canary.3", + "version": "16.2.0-canary.4", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 4479004174885..51efb5387c535 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.2.0-canary.3", + "version": "16.2.0-canary.4", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.2.0-canary.3", + "@next/eslint-plugin-next": "16.2.0-canary.4", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index 16d42fa1d54d1..6953eb2d3ceac 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.2.0-canary.3", + "version": "16.2.0-canary.4", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index c59c224fe1587..e2cb308bea1fd 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.2.0-canary.3", + "version": "16.2.0-canary.4", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index ca18d80b0afb0..e0728cb3ee9b0 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "16.2.0-canary.3", + "version": "16.2.0-canary.4", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index bf3d075260dcd..72be58551ea66 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.2.0-canary.3", + "version": "16.2.0-canary.4", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 883465317d8c6..293b618488e11 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "16.2.0-canary.3", + "version": "16.2.0-canary.4", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 9889072fa0ff7..2b4c4c7b76ce9 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "16.2.0-canary.3", + "version": "16.2.0-canary.4", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index ed0926510d55f..654f50d3ed1bd 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "16.2.0-canary.3", + "version": "16.2.0-canary.4", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index f67df275dbbd6..7ccf48da184f3 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "16.2.0-canary.3", + "version": "16.2.0-canary.4", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index 96bac29212e21..fa4b0cfd4c458 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.2.0-canary.3", + "version": "16.2.0-canary.4", "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", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index b9294c828472a..8c65ed8b82d1f 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.2.0-canary.3", + "version": "16.2.0-canary.4", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json index 6cae90bd69d72..70408692c3233 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -1,6 +1,6 @@ { "name": "@next/routing", - "version": "16.2.0-canary.3", + "version": "16.2.0-canary.4", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index b76140b3c208d..f18143497d0ef 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "16.2.0-canary.3", + "version": "16.2.0-canary.4", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 7dc19aba0e41b..f079f46117de3 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "16.2.0-canary.3", + "version": "16.2.0-canary.4", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index 07b0f96033759..99bd41afd6c59 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.2.0-canary.3", + "version": "16.2.0-canary.4", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -97,7 +97,7 @@ ] }, "dependencies": { - "@next/env": "16.2.0-canary.3", + "@next/env": "16.2.0-canary.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", @@ -162,11 +162,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.2.0-canary.3", - "@next/polyfill-module": "16.2.0-canary.3", - "@next/polyfill-nomodule": "16.2.0-canary.3", - "@next/react-refresh-utils": "16.2.0-canary.3", - "@next/swc": "16.2.0-canary.3", + "@next/font": "16.2.0-canary.4", + "@next/polyfill-module": "16.2.0-canary.4", + "@next/polyfill-nomodule": "16.2.0-canary.4", + "@next/react-refresh-utils": "16.2.0-canary.4", + "@next/swc": "16.2.0-canary.4", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.51.1", "@rspack/core": "1.6.7", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index 7f380303d4bb4..9cae9b7f3acbb 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.2.0-canary.3", + "version": "16.2.0-canary.4", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index ef154beb6e542..516919a969847 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "16.2.0-canary.3", + "version": "16.2.0-canary.4", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "16.2.0-canary.3", + "next": "16.2.0-canary.4", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "5.9.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60d1b2c177766..36ca135d62fab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1008,7 +1008,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 16.2.0-canary.3 + specifier: 16.2.0-canary.4 version: link:../eslint-plugin-next eslint: specifier: '>=9.0.0' @@ -1085,7 +1085,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 16.2.0-canary.3 + specifier: 16.2.0-canary.4 version: link:../next-env '@swc/helpers': specifier: 0.5.15 @@ -1213,19 +1213,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 16.2.0-canary.3 + specifier: 16.2.0-canary.4 version: link:../font '@next/polyfill-module': - specifier: 16.2.0-canary.3 + specifier: 16.2.0-canary.4 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 16.2.0-canary.3 + specifier: 16.2.0-canary.4 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 16.2.0-canary.3 + specifier: 16.2.0-canary.4 version: link:../react-refresh-utils '@next/swc': - specifier: 16.2.0-canary.3 + specifier: 16.2.0-canary.4 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1943,7 +1943,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 16.2.0-canary.3 + specifier: 16.2.0-canary.4 version: link:../next outdent: specifier: 0.8.0 From d1616083289a7979738112aacef993ebe9a22368 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Thu, 22 Jan 2026 23:34:44 +0100 Subject: [PATCH 3/3] remove gt workflow from agents.md (#88918) --- AGENTS.md | 58 +------------------------------------------------------ 1 file changed, 1 insertion(+), 57 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f2aab7eafe66e..c930054110685 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,62 +39,6 @@ The main Next.js framework lives in `packages/next/`. This is what gets publishe - `packages/font/` - `next/font` implementation - `packages/third-parties/` - Third-party script integrations -## Git Workflow - -**CRITICAL: Use Graphite (`gt`) instead of git for ALL branch and commit operations.** - -NEVER use these git commands directly: - -- `git push` → use `gt submit --no-edit` -- `git branch` → use `gt create` - -**Graphite commands:** - -- `gt create -m "message"` - Create a new branch with commit -- `gt modify -a --no-edit` - Stage all and amend current branch's commit -- `gt checkout ` - Switch branches -- `gt sync` - Sync and restack all branches -- `gt submit --no-edit` - Push and create/update PRs -- `gt log short` - View stack status - -**Note**: `gt submit` runs in interactive mode by default and won't push in automated contexts. Always use `gt submit --no-edit` or `gt submit -q` when running from Claude. - -**Creating PRs with descriptions**: All PRs created require a description. `gt submit --no-edit` creates PRs in draft mode without a description. To add a PR title and description, use `gh pr edit` immediately after submitting. The PR description needs to follow the mandatory format of .github/pull_request_template.md in the repository: - -```bash -gt submit --no-edit -gh pr edit --body "Place description here" -``` - -**Graphite Stack Safety Rules:** - -- Graphite force-pushes everything - old commits only recoverable via reflog -- Never have uncommitted changes when switching branches - they get lost during restack -- Never use `git stash` with Graphite - causes conflicts when `gt modify` restacks -- Never use `git checkout HEAD -- ` after editing - silently restores unfixed version -- Always use `gt checkout` (not `git checkout`) to switch branches -- `gt modify --no-edit` with unstaged/untracked files stages ALL changes -- `gt sync` pulls FROM remote, doesn't push TO remote -- `gt modify` restacks children locally but doesn't push them -- Always verify with `git status -sb` after stack operations -- When resuming from summarized conversation, never trust cached IDs - re-fetch from git/GitHub API - -**Safe multi-branch fix workflow:** - -```bash -gt checkout parent-branch -# make edits -gt modify -a --no-edit # Stage all, amend, restack children -git show HEAD -- # VERIFY fix is in commit -gt submit --no-edit # Push immediately - -gt checkout child-branch # Already restacked from gt modify -# make edits -gt modify -a --no-edit -git show HEAD -- # VERIFY -gt submit --no-edit -``` - ## Build Commands ```bash @@ -135,7 +79,7 @@ Only use full `pnpm --filter=next build` for one-off builds (after branch switch **Always rebuild after switching branches:** ```bash -gt checkout +git checkout pnpm build # Required before running tests (Turborepo dedupes if unchanged) ```