From 64f4c33ef49672d21fb52f7741429d642e9b6444 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:37:31 -0700 Subject: [PATCH 1/4] fix(next): derive workflow project root --- .changeset/next-root-detection.md | 5 ++ packages/next/src/index.test.ts | 57 ++++++++++++++++++++ packages/next/src/index.ts | 90 +++++++++++++++++++++++++++++-- 3 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 .changeset/next-root-detection.md diff --git a/.changeset/next-root-detection.md b/.changeset/next-root-detection.md new file mode 100644 index 0000000000..e16f8d1d37 --- /dev/null +++ b/.changeset/next-root-detection.md @@ -0,0 +1,5 @@ +--- +"@workflow/next": patch +--- + +Derive the workflow builder project root from Next.js workspace root configuration. diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 6996e331b3..8c75a619e3 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -123,6 +123,63 @@ describe('withWorkflow builder config', () => { }); }); + it('uses outputFileTracingRoot before turbopack root', async () => { + const config = withWorkflow({ + outputFileTracingRoot: '/trace-root', + turbopack: { root: '/turbo-root' }, + }); + + await config('phase-production-build', { + defaultConfig: {}, + }); + + expect(builderConfigs[0]).toMatchObject({ + projectRoot: '/trace-root', + moduleSpecifierRoot: process.cwd(), + workingDir: process.cwd(), + }); + }); + + it('uses turbopack root when outputFileTracingRoot is not set', async () => { + const config = withWorkflow({ + turbopack: { root: '/turbo-root' }, + }); + + await config('phase-production-build', { + defaultConfig: {}, + }); + + expect(builderConfigs[0]).toMatchObject({ + projectRoot: '/turbo-root', + moduleSpecifierRoot: process.cwd(), + workingDir: process.cwd(), + }); + }); + + it('derives projectRoot from the Next.js workspace root detection markers', async () => { + const repoRoot = mkdtempSync(join(realTmpDir, 'workflow-next-root-')); + const appRoot = join(repoRoot, 'apps/web'); + mkdirSync(appRoot, { recursive: true }); + writeFile(join(repoRoot, 'pnpm-workspace.yaml'), 'packages: []\n'); + process.chdir(appRoot); + + try { + const config = withWorkflow({}); + + await config('phase-production-build', { + defaultConfig: {}, + }); + + expect(builderConfigs[0]).toMatchObject({ + projectRoot: repoRoot, + moduleSpecifierRoot: appRoot, + workingDir: appRoot, + }); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + it.each([ 'phase-production-build', 'phase-development-server', diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 18262b2c51..4c5cc996ca 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -1,6 +1,6 @@ import { copyFileSync, mkdirSync, statSync } from 'node:fs'; import { copyFile, mkdir, readFile } from 'node:fs/promises'; -import { dirname, isAbsolute, join } from 'node:path'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; import type { NextConfig } from 'next'; import semver from 'semver'; import { getNextBuilder } from './builder.js'; @@ -16,6 +16,14 @@ const VERCEL_WORLD_SERVER_EXTERNAL_PACKAGES = [ VERCEL_WORLD_PACKAGE, ...VERCEL_WORLD_DEPENDENCY_PACKAGES, ]; +const WORKSPACE_MARKER_FILES = ['pnpm-workspace.yaml']; +const WORKSPACE_LOCK_FILES = [ + 'pnpm-lock.yaml', + 'package-lock.json', + 'yarn.lock', + 'bun.lock', + 'bun.lockb', +]; const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m; const useStepPattern = /^\s*(['"])use step\1;?\s*$/m; @@ -232,6 +240,76 @@ function fileExists(path: string): boolean { } } +function findFirstExistingUp( + cwd: string, + filenames: readonly string[] +): string | undefined { + let current = resolve(cwd); + + while (true) { + for (const filename of filenames) { + const candidate = join(current, filename); + if (fileExists(candidate)) { + return candidate; + } + } + + const parent = dirname(current); + if (parent === current) { + return undefined; + } + current = parent; + } +} + +function findNextWorkRootMarker(cwd: string): string | undefined { + return ( + findFirstExistingUp(cwd, WORKSPACE_MARKER_FILES) ?? + findFirstExistingUp(cwd, WORKSPACE_LOCK_FILES) + ); +} + +function findNextProjectRoot(workingDir: string): string { + let marker = findNextWorkRootMarker(workingDir); + if (!marker) { + return workingDir; + } + + while (true) { + const markerDir = dirname(marker); + const parentDir = dirname(markerDir); + if (parentDir === markerDir) { + return markerDir; + } + + const parentMarker = findNextWorkRootMarker(parentDir); + if (!parentMarker) { + return markerDir; + } + marker = parentMarker; + } +} + +function getTurbopackRoot(nextConfig: NextConfig): string | undefined { + const root = (nextConfig.turbopack as { root?: unknown } | undefined)?.root; + return typeof root === 'string' && root.length > 0 ? root : undefined; +} + +function resolveNextProjectRoot( + nextConfig: NextConfig, + workingDir: string +): string { + const configuredRoot = + nextConfig.outputFileTracingRoot || getTurbopackRoot(nextConfig); + if (configuredRoot) { + return isAbsolute(configuredRoot) + ? configuredRoot + : resolve(configuredRoot); + } + + return findNextProjectRoot(workingDir); +} + function getWorkflowManifestCopyPaths({ projectDir, distDir, @@ -442,7 +520,9 @@ export function withWorkflow( nextConfig.turbopack.rules = {}; } const existingRules = nextConfig.turbopack.rules as any; - const nextVersion = resolveNextVersion(process.cwd()); + const workingDir = process.cwd(); + const nextVersion = resolveNextVersion(workingDir); + const projectRoot = resolveNextProjectRoot(nextConfig, workingDir); const supportsTurboCondition = semver.gte(nextVersion, 'v16.0.0'); const shouldWatch = process.env.NODE_ENV === 'development'; @@ -474,9 +554,9 @@ export function withWorkflow( 'jsx', 'js', ], - projectRoot: nextConfig.outputFileTracingRoot, - moduleSpecifierRoot: process.cwd(), - workingDir: process.cwd(), + projectRoot, + moduleSpecifierRoot: workingDir, + workingDir, distDir, diagnosticsDir: `${distDir}/diagnostics`, buildTarget: 'next', From bd8985ce2cbe6cad61c7a185d449e4f8ebafbf40 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:42:11 -0700 Subject: [PATCH 2/4] test(next): clean temp root after leaving app dir --- packages/next/src/index.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 8c75a619e3..8469161889 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -176,6 +176,7 @@ describe('withWorkflow builder config', () => { workingDir: appRoot, }); } finally { + process.chdir(originalCwd); rmSync(repoRoot, { recursive: true, force: true }); } }); From 2b0ae702f9248097fba524d2886fa969612cc898 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:39:28 -0700 Subject: [PATCH 3/4] chore(next): simplify project root detection --- packages/next/src/index.test.ts | 35 +------------- packages/next/src/index.ts | 82 ++++++--------------------------- 2 files changed, 15 insertions(+), 102 deletions(-) diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 8469161889..e27a4fe714 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -123,40 +123,7 @@ describe('withWorkflow builder config', () => { }); }); - it('uses outputFileTracingRoot before turbopack root', async () => { - const config = withWorkflow({ - outputFileTracingRoot: '/trace-root', - turbopack: { root: '/turbo-root' }, - }); - - await config('phase-production-build', { - defaultConfig: {}, - }); - - expect(builderConfigs[0]).toMatchObject({ - projectRoot: '/trace-root', - moduleSpecifierRoot: process.cwd(), - workingDir: process.cwd(), - }); - }); - - it('uses turbopack root when outputFileTracingRoot is not set', async () => { - const config = withWorkflow({ - turbopack: { root: '/turbo-root' }, - }); - - await config('phase-production-build', { - defaultConfig: {}, - }); - - expect(builderConfigs[0]).toMatchObject({ - projectRoot: '/turbo-root', - moduleSpecifierRoot: process.cwd(), - workingDir: process.cwd(), - }); - }); - - it('derives projectRoot from the Next.js workspace root detection markers', async () => { + it('derives projectRoot from the nearest pnpm workspace root', async () => { const repoRoot = mkdtempSync(join(realTmpDir, 'workflow-next-root-')); const appRoot = join(repoRoot, 'apps/web'); mkdirSync(appRoot, { recursive: true }); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 4c5cc996ca..523cf279ca 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -16,15 +16,6 @@ const VERCEL_WORLD_SERVER_EXTERNAL_PACKAGES = [ VERCEL_WORLD_PACKAGE, ...VERCEL_WORLD_DEPENDENCY_PACKAGES, ]; -const WORKSPACE_MARKER_FILES = ['pnpm-workspace.yaml']; -const WORKSPACE_LOCK_FILES = [ - 'pnpm-lock.yaml', - 'package-lock.json', - 'yarn.lock', - 'bun.lock', - 'bun.lockb', -]; - const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m; const useStepPattern = /^\s*(['"])use step\1;?\s*$/m; const workflowSerdeImportPattern = /from\s+(['"])@workflow\/serde\1/; @@ -240,76 +231,31 @@ function fileExists(path: string): boolean { } } -function findFirstExistingUp( - cwd: string, - filenames: readonly string[] -): string | undefined { - let current = resolve(cwd); +function resolveNextProjectRoot( + nextConfig: NextConfig, + workingDir: string +): string { + if (nextConfig.outputFileTracingRoot) { + return isAbsolute(nextConfig.outputFileTracingRoot) + ? nextConfig.outputFileTracingRoot + : resolve(workingDir, nextConfig.outputFileTracingRoot); + } + + let current = resolve(workingDir); while (true) { - for (const filename of filenames) { - const candidate = join(current, filename); - if (fileExists(candidate)) { - return candidate; - } + if (fileExists(join(current, 'pnpm-workspace.yaml'))) { + return current; } const parent = dirname(current); if (parent === current) { - return undefined; + return workingDir; } current = parent; } } -function findNextWorkRootMarker(cwd: string): string | undefined { - return ( - findFirstExistingUp(cwd, WORKSPACE_MARKER_FILES) ?? - findFirstExistingUp(cwd, WORKSPACE_LOCK_FILES) - ); -} - -function findNextProjectRoot(workingDir: string): string { - let marker = findNextWorkRootMarker(workingDir); - if (!marker) { - return workingDir; - } - - while (true) { - const markerDir = dirname(marker); - const parentDir = dirname(markerDir); - if (parentDir === markerDir) { - return markerDir; - } - - const parentMarker = findNextWorkRootMarker(parentDir); - if (!parentMarker) { - return markerDir; - } - marker = parentMarker; - } -} - -function getTurbopackRoot(nextConfig: NextConfig): string | undefined { - const root = (nextConfig.turbopack as { root?: unknown } | undefined)?.root; - return typeof root === 'string' && root.length > 0 ? root : undefined; -} - -function resolveNextProjectRoot( - nextConfig: NextConfig, - workingDir: string -): string { - const configuredRoot = - nextConfig.outputFileTracingRoot || getTurbopackRoot(nextConfig); - if (configuredRoot) { - return isAbsolute(configuredRoot) - ? configuredRoot - : resolve(configuredRoot); - } - - return findNextProjectRoot(workingDir); -} - function getWorkflowManifestCopyPaths({ projectDir, distDir, From d13fa5e059f9eff0040c67ad26dc0dad40ca908a Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:58:48 -0700 Subject: [PATCH 4/4] chore(next): mirror root detection --- packages/next/src/index.test.ts | 25 ------------- packages/next/src/index.ts | 65 +++++++++++++++++++++++++++------ 2 files changed, 54 insertions(+), 36 deletions(-) diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index e27a4fe714..6996e331b3 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -123,31 +123,6 @@ describe('withWorkflow builder config', () => { }); }); - it('derives projectRoot from the nearest pnpm workspace root', async () => { - const repoRoot = mkdtempSync(join(realTmpDir, 'workflow-next-root-')); - const appRoot = join(repoRoot, 'apps/web'); - mkdirSync(appRoot, { recursive: true }); - writeFile(join(repoRoot, 'pnpm-workspace.yaml'), 'packages: []\n'); - process.chdir(appRoot); - - try { - const config = withWorkflow({}); - - await config('phase-production-build', { - defaultConfig: {}, - }); - - expect(builderConfigs[0]).toMatchObject({ - projectRoot: repoRoot, - moduleSpecifierRoot: appRoot, - workingDir: appRoot, - }); - } finally { - process.chdir(originalCwd); - rmSync(repoRoot, { recursive: true, force: true }); - } - }); - it.each([ 'phase-production-build', 'phase-development-server', diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 523cf279ca..d31470d3a3 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -231,28 +231,71 @@ function fileExists(path: string): boolean { } } +function findRootFile(names: string[], workingDir: string): string | undefined { + let current = resolve(workingDir); + + while (true) { + for (const name of names) { + const file = join(current, name); + if (fileExists(file)) { + return file; + } + } + + const parent = dirname(current); + if (parent === current) { + return undefined; + } + current = parent; + } +} + +function findNextRootFile(workingDir: string): string | undefined { + return ( + findRootFile(['pnpm-workspace.yaml'], workingDir) ?? + findRootFile( + [ + 'pnpm-lock.yaml', + 'package-lock.json', + 'yarn.lock', + 'bun.lock', + 'bun.lockb', + ], + workingDir + ) + ); +} + function resolveNextProjectRoot( nextConfig: NextConfig, workingDir: string ): string { - if (nextConfig.outputFileTracingRoot) { - return isAbsolute(nextConfig.outputFileTracingRoot) - ? nextConfig.outputFileTracingRoot - : resolve(workingDir, nextConfig.outputFileTracingRoot); + const configuredRoot = + nextConfig.outputFileTracingRoot ?? nextConfig.turbopack?.root; + + if (configuredRoot) { + return isAbsolute(configuredRoot) + ? configuredRoot + : resolve(workingDir, configuredRoot); } - let current = resolve(workingDir); + let rootFile = findNextRootFile(workingDir); + if (!rootFile) { + return workingDir; + } while (true) { - if (fileExists(join(current, 'pnpm-workspace.yaml'))) { - return current; + const currentDir = dirname(rootFile); + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + return currentDir; } - const parent = dirname(current); - if (parent === current) { - return workingDir; + const parentRootFile = findNextRootFile(parentDir); + if (!parentRootFile) { + return currentDir; } - current = parent; + rootFile = parentRootFile; } }