Skip to content

Commit 480c372

Browse files
committed
perf(electron): build the Next standalone once and hydrate natives per leg (#10321 stage 8)
The desktop release matrix ran the full Next.js standalone build on all four legs (windows, macos-intel, macos-arm64, linux), duplicating the platform-neutral majority of that work four times and re-exposing every leg to the hosted-runner RAM class of failure that took the linux leg out of v3.8.49. - scripts/build/standaloneTarball.mjs: deterministic, dependency-free tar.gz writer/reader (uid/gid/mtime pinned, sorted entries, symlink + exec-bit preservation; GNU-tar interop covered by tests). - scripts/build/standaloneManifest.mjs: byte-level manifest of .build/next (sha256 + size + symlink target per entry, plus the archive's own digest) catching artifact-transfer corruption before extraction and re-verifying the restored tree byte-for-byte, smuggling included. - scripts/build/standaloneBundle.mjs: pack / restore / hydrate CLI over the two modules above. - scripts/build/hydrateNativeDeps.mjs: swaps install-machine-forked native optionals (@img/sharp-*, @ngrok/ngrok-*, fsevents) from the leg's own npm ci into the restored tree, then verifies the bundled-native closure (koffi triplets, better-sqlite3 prebuilds, wreq-js, onnxruntime with its documented darwin-x64 exemption) services the leg's platform/arch before packaging starts. - .github/workflows/electron-release.yml: new web-build job builds the standalone once on ubuntu with webpack and uploads the bundle; legs download, restore, and hydrate it, skipping the per-leg build. The legacy per-leg build remains as a rollback path via the ELECTRON_SHARED_STANDALONE workflow_dispatch input, and legs fail closed if web-build ran and failed. Regression tests cover archive roundtrip, byte determinism, manifest tamper/smuggle detection, forked-native swaps, and native-closure serviceability.
1 parent ee221d8 commit 480c372

6 files changed

Lines changed: 1279 additions & 2 deletions

File tree

.github/workflows/electron-release.yml

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,75 @@ jobs:
5555
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
5656
echo "✓ Valid version: $VERSION"
5757
58+
web-build:
59+
name: Build shared Next standalone
60+
needs: validate
61+
# Stage 8 (issue #10321): the four desktop legs used to each run the full
62+
# `npm run build` (Next standalone) — ~111 runner-minutes per release just to
63+
# produce the same platform-independent bundle four times. This job builds it
64+
# once on ubuntu; every leg then restores the byte-verified archive and
65+
# re-forks its native optionals (scripts/build/standaloneBundle.mjs).
66+
#
67+
# Rollback lever: set the repo variable ELECTRON_SHARED_STANDALONE=disabled.
68+
# This job then skips, every leg falls back to building its own web bundle
69+
# (the legacy step below), and the pipeline behaves exactly like pre-Stage 8 —
70+
# no revert needed.
71+
if: ${{ !cancelled() && needs.validate.result == 'success' && vars.ELECTRON_SHARED_STANDALONE != 'disabled' }}
72+
runs-on: ubuntu-latest
73+
permissions:
74+
contents: read
75+
steps:
76+
- uses: actions/checkout@v7
77+
with:
78+
persist-credentials: false
79+
- name: Setup Node
80+
uses: actions/setup-node@v7
81+
with:
82+
node-version: 24
83+
84+
- name: Install dependencies
85+
run: npm ci
86+
env:
87+
NPM_CONFIG_LEGACY_PEER_DEPS: true
88+
89+
- name: Build Next.js standalone
90+
# webpack, not Turbopack, for the same hosted-runner RAM reason as the
91+
# linux leg (see the long comment on the fallback step in `build`).
92+
env:
93+
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
94+
NODE_OPTIONS: "--max_old_space_size=6144"
95+
OMNIROUTE_USE_TURBOPACK: "0"
96+
run: npm run build
97+
98+
- name: Pack standalone bundle
99+
# Deterministic tar.gz + byte-level manifest; the manifest embeds the
100+
# archive's own sha256 so artifact-transfer corruption is caught before
101+
# extraction, and every entry is re-verified after extraction.
102+
run: node scripts/build/standaloneBundle.mjs pack --out web-bundle.tar.gz
103+
104+
- name: Upload shared web bundle
105+
uses: actions/upload-artifact@v7
106+
with:
107+
name: web-standalone-bundle
108+
# compression-level 0: the payload is already a deterministic tar.gz;
109+
# re-zipping would only burn runner CPU without shrinking it further.
110+
compression-level: 0
111+
# Legs consume this within minutes; no reason to retain it like the
112+
# installer artifacts (default 90d).
113+
retention-days: 3
114+
path: |
115+
web-bundle.tar.gz
116+
web-bundle.tar.gz.manifest.json
117+
58118
build:
59119
name: Build Electron (${{ matrix.platform }})
60-
needs: validate
120+
needs: [validate, web-build]
121+
# `web-build` is skipped when ELECTRON_SHARED_STANDALONE=disabled (rollback
122+
# mode); legs then run the legacy per-leg web build below. If it ran and
123+
# failed, fail closed: legs cannot package without the bundle, and silently
124+
# falling back to four per-leg builds would hide exactly the regression the
125+
# shared job exists to surface.
126+
if: ${{ !cancelled() && needs.validate.result == 'success' && (needs.web-build.result == 'success' || needs.web-build.result == 'skipped') }}
61127
runs-on: ${{ matrix.runner }}
62128
permissions:
63129
contents: write # electron-builder may publish artifacts with GH_TOKEN
@@ -69,19 +135,27 @@ jobs:
69135
runner: windows-latest
70136
target: win
71137
ext: .exe
138+
os: win32
139+
arch: x64
72140
- platform: macos-intel
73141
runner: macos-15-intel
74142
target: mac-x64
75143
ext: .dmg
144+
os: darwin
145+
arch: x64
76146
- platform: macos-arm64
77147
runner: macos-latest
78148
target: mac-arm64
79149
ext: -arm64.dmg
150+
os: darwin
151+
arch: arm64
80152
- platform: linux
81153
runner: ubuntu-latest
82154
target: linux
83155
ext: .AppImage
84156
deb_ext: .deb
157+
os: linux
158+
arch: x64,arm64
85159

86160
steps:
87161
- uses: actions/checkout@v7
@@ -116,7 +190,11 @@ jobs:
116190
mkdir -p "$RUNNER_TEMP/home"
117191
echo "USERPROFILE=$RUNNER_TEMP/home" >> "$GITHUB_ENV"
118192
119-
- name: Build Next.js standalone
193+
- name: Build Next.js standalone (legacy per-leg fallback)
194+
# Stage 8: only runs in rollback mode (ELECTRON_SHARED_STANDALONE=disabled)
195+
# or when the shared web-build job was skipped. Otherwise the leg restores
196+
# the shared bundle from the `web-build` job below.
197+
if: needs.web-build.result == 'skipped'
120198
env:
121199
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
122200
NODE_OPTIONS: "--max_old_space_size=6144"
@@ -134,6 +212,30 @@ jobs:
134212
OMNIROUTE_USE_TURBOPACK: ${{ matrix.platform == 'linux' && '0' || '1' }}
135213
run: npm run build
136214

215+
- name: Download shared web bundle
216+
# Stage 8: inverse of the fallback step above — runs exactly when the
217+
# shared `web-build` job produced the bundle.
218+
if: needs.web-build.result == 'success'
219+
uses: actions/download-artifact@v8
220+
with:
221+
name: web-standalone-bundle
222+
223+
- name: Restore + hydrate shared web bundle
224+
if: needs.web-build.result == 'success'
225+
shell: bash
226+
# restore: verify the archive's sha256 against the manifest, extract, then
227+
# re-verify every entry (existence + size + content hash + symlink
228+
# targets, and no unlisted files) byte-for-byte.
229+
# hydrate: the bundle was built on ubuntu, so install-machine-forked native
230+
# optionals (@img/sharp-*, @img/sharp-libvips-*, @ngrok/ngrok-*,
231+
# fsevents) carry linux forks. Replace them with the forks this
232+
# leg's own `npm ci` resolved, then assert every bundled native
233+
# (koffi triplets, better-sqlite3 prebuilds, wreq-js, onnxruntime)
234+
# can service this leg's platform/arch before packaging starts.
235+
run: |
236+
node scripts/build/standaloneBundle.mjs restore --archive web-bundle.tar.gz
237+
node scripts/build/standaloneBundle.mjs hydrate --platform ${{ matrix.os }} --arch ${{ matrix.arch }}
238+
137239
- name: Sync version in electron/package.json
138240
shell: bash
139241
env:
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Platform hydration for the shared Next standalone web build (issue #10321,
4+
* Stage 8).
5+
*
6+
* The standalone bundle is built ONCE on ubuntu and restored on every desktop
7+
* matrix leg. Everything except install-machine-forked optional packages is
8+
* platform-independent:
9+
*
10+
* - Bundled-for-all (verify only): koffi ships every triplet under
11+
* `build/koffi/<os>_<arch>`, better-sqlite3 v13 ships Node-API prebuilds for
12+
* 8 platforms, wreq-js ships `rust/wreq-js.<plat>-<arch>[-libc].node`, and
13+
* onnxruntime-node ships `bin/napi-v6/<os>/<arch>`.
14+
* - Install-machine-forked (hydrate): `@img/sharp-*`, `@img/sharp-libvips-*`,
15+
* `@ngrok/ngrok-*` and macOS-only `fsevents` resolve to whichever platform
16+
* ran `npm ci`. The ubuntu-built tree carries the linux forks; each leg
17+
* replaces them with the forks from its OWN `npm ci`d node_modules.
18+
*/
19+
20+
import fs from "node:fs";
21+
import path from "node:path";
22+
23+
/** Scope prefixes whose members are install-machine-forked. */
24+
export const HYDRATED_SCOPES = ["@img/sharp-", "@img/sharp-libvips-", "@ngrok/ngrok-"];
25+
26+
/** Standalone packages that are not forked but must never be platform-forked. */
27+
export const HYDRATED_ROOT_PACKAGES = ["fsevents"];
28+
29+
/**
30+
* onnxruntime-node does not publish a darwin-x64 binary for napi-v6 (only
31+
* linux/win32 x64 + darwin arm64), so existence cannot be asserted there.
32+
*/
33+
export const BUNDLED_EXEMPTIONS = new Set(["onnxruntime-node:darwin-x64"]);
34+
35+
function platformTriple(platform, arch) {
36+
// koffi uses underscore triplets; better-sqlite3/wreq-js/onnx use dashes.
37+
return { koffi: `${platform}_${arch}`, dash: `${platform}-${arch}` };
38+
}
39+
40+
function rmrf(target) {
41+
fs.rmSync(target, { recursive: true, force: true });
42+
}
43+
44+
function copyDir(from, to) {
45+
fs.cpSync(from, to, { recursive: true, verbatimSymlinks: false, force: true });
46+
}
47+
48+
function directMemberNames(nodeModulesDir, scope) {
49+
const scopeDir = path.join(nodeModulesDir, ...scope.split("/").slice(0, -1));
50+
const prefix = scope.split("/").pop();
51+
try {
52+
return fs
53+
.readdirSync(scopeDir)
54+
.filter((name) => name.startsWith(prefix))
55+
.map((name) => `${scope.slice(0, scope.lastIndexOf("/"))}/${name}`);
56+
} catch {
57+
return [];
58+
}
59+
}
60+
61+
/**
62+
* Replace install-machine-forked packages inside the restored standalone tree
63+
* with the forks resolved by THIS machine's node_modules.
64+
*
65+
* @param {{standaloneNodeModules: string, sourceNodeModules: string}} opts
66+
* @returns {{replaced: string[], removed: string[], copied: string[]}}
67+
*/
68+
export function hydratePlatformNatives({ standaloneNodeModules, sourceNodeModules }) {
69+
const replaced = [];
70+
const removed = [];
71+
const copied = [];
72+
73+
const forkedNames = new Set();
74+
for (const scope of HYDRATED_SCOPES) {
75+
for (const name of directMemberNames(sourceNodeModules, scope)) forkedNames.add(name);
76+
for (const name of directMemberNames(standaloneNodeModules, scope)) forkedNames.add(name);
77+
}
78+
for (const pkg of HYDRATED_ROOT_PACKAGES) {
79+
if (fs.existsSync(path.join(sourceNodeModules, pkg))) forkedNames.add(pkg);
80+
if (fs.existsSync(path.join(standaloneNodeModules, pkg))) forkedNames.add(pkg);
81+
}
82+
83+
for (const name of forkedNames) {
84+
const standalonePath = path.join(standaloneNodeModules, ...name.split("/"));
85+
const sourcePath = path.join(sourceNodeModules, ...name.split("/"));
86+
const hadIt = fs.existsSync(standalonePath);
87+
const hasIt = fs.existsSync(sourcePath);
88+
if (hadIt) rmrf(standalonePath);
89+
if (!hasIt) {
90+
if (hadIt) removed.push(name);
91+
continue; // e.g. fsevents on non-darwin legs: simply absent everywhere.
92+
}
93+
copyDir(sourcePath, standalonePath);
94+
copied.push(name);
95+
if (hadIt) replaced.push(name);
96+
}
97+
return { replaced, removed, copied };
98+
}
99+
100+
/**
101+
* Assert that every bundled native dependency can service `platform`/`arch`.
102+
*
103+
* @returns {{ok: true} | {ok: false, errors: string[]}}
104+
*/
105+
export function verifyBundledNatives({ nodeModulesDir, platform, arch }) {
106+
const errors = [];
107+
const triple = platformTriple(platform, arch);
108+
109+
const koffiDir = path.join(nodeModulesDir, "koffi", "build", "koffi", triple.koffi);
110+
if (!fs.existsSync(koffiDir)) errors.push(`koffi: missing bundled triplet ${triple.koffi}`);
111+
112+
const sqlitePrebuild = path.join(
113+
nodeModulesDir,
114+
"better-sqlite3",
115+
"prebuilds",
116+
`${triple.dash}.node`
117+
);
118+
if (!fs.existsSync(sqlitePrebuild))
119+
errors.push(`better-sqlite3: missing prebuild ${triple.dash}.node`);
120+
121+
const wreqDir = path.join(nodeModulesDir, "wreq-js", "rust");
122+
const wreqNames = fs.existsSync(wreqDir)
123+
? fs
124+
.readdirSync(wreqDir)
125+
.filter((n) => n.startsWith(`wreq-js.${triple.dash}`) && n.endsWith(".node"))
126+
: [];
127+
if (wreqNames.length === 0) errors.push(`wreq-js: missing rust binary for ${triple.dash}`);
128+
129+
const exempt = BUNDLED_EXEMPTIONS.has(`onnxruntime-node:${triple.dash}`);
130+
if (!exempt) {
131+
const onnxDir = path.join(nodeModulesDir, "onnxruntime-node", "bin", "napi-v6", platform, arch);
132+
if (!fs.existsSync(onnxDir))
133+
errors.push(`onnxruntime-node: missing ${platform}/${arch} binary`);
134+
}
135+
136+
return errors.length === 0 ? { ok: true } : { ok: false, errors };
137+
}

0 commit comments

Comments
 (0)