fix(build): isolate Windows HOME/AppData during next build (#7249)

* 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 <kunn21.nv@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2402

* chore(changelog): fragment for #7249

---------

Co-authored-by: KunN21 <kunn21.nv@gmail.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 10:40:59 -03:00
committed by GitHub
parent b9433fd03a
commit 480491cb3f
3 changed files with 143 additions and 2 deletions

View File

@@ -0,0 +1 @@
- **fix(build):** isolate Windows HOME/AppData during next build. (thanks @KunN-21)

View File

@@ -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,

View File

@@ -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 });
}
});