From 480491cb3fbf69def3d16b84ab933dd6de2c6dc2 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:40:59 -0300 Subject: [PATCH] fix(build): isolate Windows HOME/AppData during next build (#7249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(build): isolate Windows HOME/AppData during next build next build's static-generation glob scan and framework cache helpers walk %USERPROFILE%/AppData, which on GitHub-hosted Windows runners (and some OneDrive-backed dev profiles) contains reparse points/junctions that raise EPERM during Next's file-system scans. .github/workflows/electron-release.yml already patches USERPROFILE for that one CI job ("Sanitize Windows home directory" step), but a local `npm run build` on Windows — or any other Windows CI path that calls scripts/build/build-next-isolated.mjs directly — hits the same EPERM unprotected, and the existing CI patch does not touch APPDATA/LOCALAPPDATA at all. Folds the isolation into resolveNextBuildEnv() (the existing seam every caller of build-next-isolated.mjs already goes through), rather than adding a second build entrypoint the way upstream's scripts/build-app.js does: on win32, HOME/USERPROFILE/APPDATA/LOCALAPPDATA are pointed at a fresh per-process temp profile dir, created just-in-time via the new ensureWindowsBuildProfileDirs() before spawning `next build`. Skipped when a caller has already sandboxed the build via NEXT_DIST_DIR (the existing signal this file reads for isolated-build callers, e.g. CLI packaging), so nested build invocations are never double-isolated. Non-Windows behavior is unchanged. Co-authored-by: KunN21 Inspired-by: https://github.com/decolua/9router/pull/2402 * chore(changelog): fragment for #7249 --------- Co-authored-by: KunN21 --- .../fixes/7249-windows-build-isolation.md | 1 + scripts/build/build-next-isolated.mjs | 51 +++++++++- ...ld-next-isolated-windows-home-2402.test.ts | 93 +++++++++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fixes/7249-windows-build-isolation.md create mode 100644 tests/unit/build-next-isolated-windows-home-2402.test.ts diff --git a/changelog.d/fixes/7249-windows-build-isolation.md b/changelog.d/fixes/7249-windows-build-isolation.md new file mode 100644 index 0000000000..1c2997dc2c --- /dev/null +++ b/changelog.d/fixes/7249-windows-build-isolation.md @@ -0,0 +1 @@ +- **fix(build):** isolate Windows HOME/AppData during next build. (thanks @KunN-21) diff --git a/scripts/build/build-next-isolated.mjs b/scripts/build/build-next-isolated.mjs index 463190d430..f3e6c406ff 100644 --- a/scripts/build/build-next-isolated.mjs +++ b/scripts/build/build-next-isolated.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import fs from "node:fs/promises"; +import { mkdirSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawn } from "node:child_process"; @@ -79,13 +80,26 @@ export async function movePath(sourcePath, destinationPath, fsImpl = fs) { } } +/** + * Best-effort: physically create the isolated Windows profile dirs that + * resolveNextBuildEnv() may have pointed APPDATA/LOCALAPPDATA at. No-op when + * resolveNextBuildEnv didn't set them (non-Windows, or NEXT_DIST_DIR already set). + */ +export function ensureWindowsBuildProfileDirs(env, mkdirImpl = mkdirSync) { + if (!env?.APPDATA || !env?.LOCALAPPDATA) return; + mkdirImpl(env.APPDATA, { recursive: true }); + mkdirImpl(env.LOCALAPPDATA, { recursive: true }); +} + function runNextBuild() { return new Promise((resolve) => { const nextBin = path.join(projectRoot, "node_modules", "next", "dist", "bin", "next"); + const buildEnv = resolveNextBuildEnv(process.env); + ensureWindowsBuildProfileDirs(buildEnv); const child = spawn(process.execPath, [nextBin, "build", resolveNextBuildBundlerFlag()], { cwd: projectRoot, stdio: "inherit", - env: resolveNextBuildEnv(process.env), + env: buildEnv, }); const forward = (signal) => { @@ -116,12 +130,45 @@ export function resolveNextBuildBundlerFlag(baseEnv = process.env) { return baseEnv.OMNIROUTE_USE_TURBOPACK === "0" ? "--webpack" : "--turbopack"; } -export function resolveNextBuildEnv(baseEnv = process.env) { +/** + * Deterministic per-process isolated Windows user-profile directory, used to + * sandbox HOME/USERPROFILE/APPDATA/LOCALAPPDATA for the spawned `next build`. + * Kept as a separate helper (rather than inline in resolveNextBuildEnv) so the + * directory-creation side effect (ensureWindowsBuildProfileDirs) can be invoked + * once per real build without re-deriving the path. + */ +export function getWindowsBuildProfileDir() { + return path.join(os.tmpdir(), `omniroute-build-winhome-${process.pid}`); +} + +export function resolveNextBuildEnv(baseEnv = process.env, platform = process.platform) { const env = { ...baseEnv, NEXT_PRIVATE_BUILD_WORKER: baseEnv.NEXT_PRIVATE_BUILD_WORKER || "0", }; + // Windows-only: `next build`'s static-generation glob scan and framework cache + // helpers walk %USERPROFILE%/AppData, which on GitHub-hosted Windows runners (and + // some OneDrive-backed dev profiles) contains reparse points/junctions that raise + // EPERM during Next's file-system scans. `.github/workflows/electron-release.yml` + // ("Sanitize Windows home directory" step) already patches USERPROFILE for the CI + // runner, but that only covers the electron-release CI job — a local `npm run + // build` on Windows (or any other Windows CI path that calls this script + // directly) hits the same EPERM unprotected. Doing the isolation here covers + // every caller of build-next-isolated.mjs, not just one workflow step. Skipped + // when a caller has already sandboxed the build via NEXT_DIST_DIR (the existing + // signal this file already reads for "isolated build" callers — see `distDir` + // above) to avoid double-isolating nested build invocations. + // Port of decolua/9router#2402 ("fix(build): isolate Windows HOME/AppData + // during next build"). + if (platform === "win32" && !baseEnv.NEXT_DIST_DIR) { + const buildHomeDir = getWindowsBuildProfileDir(); + env.HOME = buildHomeDir; + env.USERPROFILE = buildHomeDir; + env.APPDATA = path.join(buildHomeDir, "AppData", "Roaming"); + env.LOCALAPPDATA = path.join(buildHomeDir, "AppData", "Local"); + } + // Raise the Node heap for the spawned `next build`. The webpack production pass // ("Compiling instrumentation" bundles the whole server graph) is the heaviest // phase and overflows V8's default ~2 GB ceiling on memory-constrained machines, diff --git a/tests/unit/build-next-isolated-windows-home-2402.test.ts b/tests/unit/build-next-isolated-windows-home-2402.test.ts new file mode 100644 index 0000000000..7c52ae7325 --- /dev/null +++ b/tests/unit/build-next-isolated-windows-home-2402.test.ts @@ -0,0 +1,93 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import fsSync from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const { + ensureWindowsBuildProfileDirs, + getWindowsBuildProfileDir, + resolveNextBuildEnv, +} = await import("../../scripts/build/build-next-isolated.mjs"); + +// Port of decolua/9router#2402 ("fix(build): isolate Windows HOME/AppData during +// next build"). Upstream wraps `npm run build` in a new `scripts/build-app.js` +// entrypoint; OmniRoute's build already routes through +// `scripts/build/build-next-isolated.mjs` → resolveNextBuildEnv(), so the fix is +// folded into that existing seam instead of adding a second build entrypoint. +// `.github/workflows/electron-release.yml` already sanitizes USERPROFILE for one +// CI job; this generalizes the isolation to every caller (local Windows builds, +// other CI paths) and adds APPDATA/LOCALAPPDATA, which the CI-only patch does not +// touch. + +test("resolveNextBuildEnv leaves HOME/USERPROFILE/APPDATA untouched on non-Windows", () => { + const env = resolveNextBuildEnv({ NODE_ENV: "test", HOME: "/home/dev" }, "linux"); + assert.equal(env.HOME, "/home/dev"); + assert.equal(env.USERPROFILE, undefined); + assert.equal(env.APPDATA, undefined); + assert.equal(env.LOCALAPPDATA, undefined); +}); + +test("resolveNextBuildEnv isolates HOME/USERPROFILE/APPDATA/LOCALAPPDATA on win32", () => { + const env = resolveNextBuildEnv( + { NODE_ENV: "test", USERPROFILE: "C:\\Users\\ci-runner" }, + "win32" + ); + + assert.ok(env.HOME, "HOME must be set to an isolated profile dir on win32"); + assert.equal(env.HOME, env.USERPROFILE, "HOME and USERPROFILE must point at the same sandbox"); + assert.notEqual( + env.USERPROFILE, + "C:\\Users\\ci-runner", + "the real USERPROFILE (with its junctions) must be replaced, not preserved" + ); + assert.match(path.basename(env.APPDATA), /^Roaming$/); + assert.match(path.basename(env.LOCALAPPDATA), /^Local$/); + assert.equal(path.dirname(path.dirname(env.APPDATA)), env.HOME); + assert.equal(path.dirname(path.dirname(env.LOCALAPPDATA)), env.HOME); +}); + +test("resolveNextBuildEnv skips Windows isolation when a caller already sandboxed the build (NEXT_DIST_DIR)", () => { + const env = resolveNextBuildEnv( + { NODE_ENV: "test", USERPROFILE: "C:\\Users\\ci-runner", NEXT_DIST_DIR: ".build/cli-next" }, + "win32" + ); + + assert.equal( + env.USERPROFILE, + "C:\\Users\\ci-runner", + "must not override a caller-provided sandbox (e.g. CLI packaging)" + ); + assert.equal(env.APPDATA, undefined); + assert.equal(env.LOCALAPPDATA, undefined); +}); + +test("getWindowsBuildProfileDir is stable per-process (repeated calls return the same path)", () => { + assert.equal(getWindowsBuildProfileDir(), getWindowsBuildProfileDir()); +}); + +test("ensureWindowsBuildProfileDirs is a no-op when the env has no APPDATA/LOCALAPPDATA", () => { + let called = false; + ensureWindowsBuildProfileDirs({ NODE_ENV: "test" }, () => { + called = true; + }); + assert.equal(called, false); +}); + +test("ensureWindowsBuildProfileDirs creates the isolated AppData directories", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-win-home-test-")); + try { + const env = { + APPDATA: path.join(tempDir, "AppData", "Roaming"), + LOCALAPPDATA: path.join(tempDir, "AppData", "Local"), + }; + + ensureWindowsBuildProfileDirs(env); + + assert.equal(fsSync.existsSync(env.APPDATA), true); + assert.equal(fsSync.existsSync(env.LOCALAPPDATA), true); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +});