Speed up Electron client packaging by filtering prebuilds
Release rolling / release (push) Has been cancelled

Only ship native prebuilds for the target platform/arch (~80MB instead
of ~500MB of multi-arch blobs), skip redundant GUI rebundles, cache
Electron downloads across hosts, and strip more packaging-only deps so
"Finalizing package" no longer crawls multi-hundred-MB asars six times.
This commit is contained in:
Raven Scott
2026-07-11 14:36:12 -04:00
parent d1fcbc5717
commit b0947ca472
3 changed files with 207 additions and 34 deletions
+4
View File
@@ -162,6 +162,9 @@ jobs:
# Prefer prebuilds; skip electron-rebuild (main "Finalizing package" stall) # Prefer prebuilds; skip electron-rebuild (main "Finalizing package" stall)
npm_config_build_from_source: 'false' npm_config_build_from_source: 'false'
PEARDOCK_SKIP_REBUILD: '1' PEARDOCK_SKIP_REBUILD: '1'
# Reuse Electron downloads across the 6 client hosts
ELECTRON_CACHE: ${{ github.workspace }}/.cache/electron
electron_config_cache: ${{ github.workspace }}/.cache/electron
# electron-packager / forge download target Electron builds # electron-packager / forge download target Electron builds
ELECTRON_GET_USE_PROXY: ${{ env.ELECTRON_GET_USE_PROXY || '' }} ELECTRON_GET_USE_PROXY: ${{ env.ELECTRON_GET_USE_PROXY || '' }}
run: | run: |
@@ -177,6 +180,7 @@ jobs:
if [ -z "${GITEA_URL:-}" ]; then if [ -z "${GITEA_URL:-}" ]; then
export GITEA_URL="${GITHUB_SERVER_URL:-}" export GITEA_URL="${GITHUB_SERVER_URL:-}"
fi fi
mkdir -p "${ELECTRON_CACHE:-$GITHUB_WORKSPACE/.cache/electron}"
echo "Server hosts: $PEARDOCK_SERVER_HOSTS" echo "Server hosts: $PEARDOCK_SERVER_HOSTS"
echo "Client hosts: $PEARDOCK_CLIENT_HOSTS" echo "Client hosts: $PEARDOCK_CLIENT_HOSTS"
chmod +x scripts/gitea-rolling-release.sh scripts/bare-standalone.cjs scripts/make.cjs chmod +x scripts/gitea-rolling-release.sh scripts/bare-standalone.cjs scripts/make.cjs
+183 -30
View File
@@ -2,9 +2,12 @@
* Electron Forge config for peardock-client. * Electron Forge config for peardock-client.
* *
* CI note: "Finalizing package" used to take forever because asar:false + prune * CI note: "Finalizing package" used to take forever because asar:false + prune
* copied ~GBs of real files. We now pack with asar (CJS require works inside * copied ~GBs of real files. We pack with asar (CJS require works inside asar;
* asar; only natives are unpacked) and prune:false (ignore list already strips * only natives are unpacked) and prune:false (ignore list already strips tooling).
* the heavy tooling). *
* Further win: only ship prebuilds for the *target* platform/arch. Holepunch
* natives ship ~500MB of multi-arch prebuilds; keeping one host (~2040MB)
* dominates finalize time.
*/ */
'use strict' 'use strict'
@@ -14,6 +17,28 @@ const fs = require('fs')
const pkg = require('./package.json') const pkg = require('./package.json')
const appName = pkg.productName || pkg.name || 'peardock' const appName = pkg.productName || pkg.name || 'peardock'
/**
* Resolve packaging target from forge CLI args or env (set by make.cjs).
* @returns {{ platform: string, arch: string }}
*/
function resolvePackageTarget() {
const argv = process.argv
const flag = (name) => {
const i = argv.indexOf(name)
return i >= 0 && argv[i + 1] ? argv[i + 1] : null
}
const platform =
process.env.PEARDOCK_PACKAGE_PLATFORM ||
flag('--platform') ||
process.platform
const arch =
process.env.PEARDOCK_PACKAGE_ARCH || flag('--arch') || process.arch
return { platform, arch }
}
const packageTarget = resolvePackageTarget()
const packageHost = `${packageTarget.platform}-${packageTarget.arch}`
/** Path prefixes (packager paths start with /) to exclude from the app bundle */ /** Path prefixes (packager paths start with /) to exclude from the app bundle */
const IGNORE_PREFIXES = [ const IGNORE_PREFIXES = [
'/.git', '/.git',
@@ -32,6 +57,8 @@ const IGNORE_PREFIXES = [
'/peardock-branding', // master brand package; runtime copies live in build/ + assets/ '/peardock-branding', // master brand package; runtime copies live in build/ + assets/
'/build/stubs', '/build/stubs',
'/build/shims', '/build/shims',
'/ROADMAP.md',
'/README.md',
// Packaging / Bare server toolchain (not needed at Electron runtime) // Packaging / Bare server toolchain (not needed at Electron runtime)
'/node_modules/electron', '/node_modules/electron',
'/node_modules/electron-', '/node_modules/electron-',
@@ -49,7 +76,12 @@ const IGNORE_PREFIXES = [
'/node_modules/bare-dev', '/node_modules/bare-dev',
'/node_modules/bare-bundle', '/node_modules/bare-bundle',
'/node_modules/bare-module-traverse', '/node_modules/bare-module-traverse',
'/node_modules/bare-sqlite', // not used by Electron client
'/node_modules/postject',
'/node_modules/@inquirer',
'/node_modules/terser',
'/node_modules/pear-runtime/', '/node_modules/pear-runtime/',
'/node_modules/pear-electron', // packaged shell is electron/main.cjs, not pear-electron
// Server-only Docker stack // Server-only Docker stack
'/node_modules/dockerode', '/node_modules/dockerode',
'/node_modules/docker-modem', '/node_modules/docker-modem',
@@ -75,26 +107,51 @@ const IGNORE_REGEX = [
/^\/node_modules\/bare-runtime-/, /^\/node_modules\/bare-runtime-/,
/^\/node_modules\/bare-pack-/, /^\/node_modules\/bare-pack-/,
/^\/node_modules\/@esbuild\//, /^\/node_modules\/@esbuild\//,
/\.md$/, /\.md$/i,
/\.map$/, /\.map$/,
/\.d\.ts$/,
/^\/peardock-.*\.json$/, /^\/peardock-.*\.json$/,
/^\/\.env$/, /^\/\.env$/,
/^\/package-lock\.json$/, /^\/package-lock\.json$/,
// Tests / docs inside deps // Tests / docs / examples inside deps
/^\/node_modules\/[^/]+\/test\//, /^\/node_modules\/[^/]+\/test\//,
/^\/node_modules\/[^/]+\/tests\//, /^\/node_modules\/[^/]+\/tests\//,
/^\/node_modules\/[^/]+\/docs\//, /^\/node_modules\/[^/]+\/docs\//,
/^\/node_modules\/[^/]+\/example\//, /^\/node_modules\/[^/]+\/example\//,
/^\/node_modules\/[^/]+\/examples\//, /^\/node_modules\/[^/]+\/examples\//,
/^\/node_modules\/[^/]+\/\.github\//,
] ]
/**
* Drop prebuilds for every host except the package target.
* Paths look like: /node_modules/rocksdb-native/prebuilds/darwin-arm64/...
*/
function isForeignPrebuild(file) {
const marker = '/prebuilds/'
const idx = file.indexOf(marker)
if (idx === -1) return false
const host = file.slice(idx + marker.length).split('/')[0]
if (!host) return false
// Always drop mobile / simulator prebuilds
if (
host.startsWith('android') ||
host.startsWith('ios') ||
host.includes('simulator')
) {
return true
}
return host !== packageHost
}
function shouldIgnore(file) { function shouldIgnore(file) {
if (!file) return false if (!file) return false
// Always keep package.json (packager needs it) // Always keep package.json (packager needs it)
if (file === '/package.json') return false if (file === '/package.json') return false
// Keep the GUI bundle (generated) // Keep the GUI bundle (exact path only — do not match .map via prefix)
if (file === '/electron/app.bundle.cjs' || file.startsWith('/electron/app.bundle.cjs')) { if (file === '/electron/app.bundle.cjs') return false
return false // Sourcemap: only when explicitly requested (default drop → faster asar)
if (file === '/electron/app.bundle.cjs.map') {
return process.env.PEARDOCK_KEEP_SOURCEMAP !== '1'
} }
for (const p of IGNORE_PREFIXES) { for (const p of IGNORE_PREFIXES) {
if (file === p || file.startsWith(p + '/') || file.startsWith(p)) return true if (file === p || file.startsWith(p + '/') || file.startsWith(p)) return true
@@ -102,6 +159,7 @@ function shouldIgnore(file) {
for (const re of IGNORE_REGEX) { for (const re of IGNORE_REGEX) {
if (re.test(file)) return true if (re.test(file)) return true
} }
if (isForeignPrebuild(file)) return true
return false return false
} }
@@ -110,6 +168,100 @@ const skipRebuild =
process.env.PEARDOCK_FORCE_REBUILD !== '1' && process.env.PEARDOCK_FORCE_REBUILD !== '1' &&
process.env.PEARDOCK_SKIP_REBUILD !== '0' process.env.PEARDOCK_SKIP_REBUILD !== '0'
/**
* After copy (before asar): belt-and-suspenders strip of foreign prebuilds + junk.
* Ignore should already exclude these; this catches nested paths packager still copied.
*/
function stripBuildPath(buildPath) {
const t0 = Date.now()
let removed = 0
function rm(rel) {
const p = path.join(buildPath, rel)
try {
if (fs.existsSync(p)) {
fs.rmSync(p, { recursive: true, force: true })
removed++
}
} catch {
// ignore
}
}
const junk = [
'node_modules/bare-sidecar',
'node_modules/electron',
'node_modules/@electron-forge',
'node_modules/bare-build',
'node_modules/bare-runtime',
'node_modules/pear-runtime',
'node_modules/pear-electron',
'node_modules/esbuild',
'node_modules/bare-sqlite',
'node_modules/postject',
'node_modules/@inquirer',
'node_modules/terser',
'server',
'scripts',
'out',
'test',
'docs',
'peardock-branding',
'ROADMAP.md',
'README.md',
]
for (const rel of junk) rm(rel)
// Walk node_modules for prebuilds/<host> dirs
const nm = path.join(buildPath, 'node_modules')
if (fs.existsSync(nm)) {
const stack = [nm]
while (stack.length) {
const dir = stack.pop()
let entries
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
continue
}
for (const ent of entries) {
const full = path.join(dir, ent.name)
if (!ent.isDirectory()) continue
if (ent.name === 'prebuilds') {
let hosts
try {
hosts = fs.readdirSync(full)
} catch {
continue
}
for (const host of hosts) {
if (host === packageHost) continue
try {
fs.rmSync(path.join(full, host), { recursive: true, force: true })
removed++
} catch {
// ignore
}
}
continue
}
// Do not descend into prebuilds we already handled
if (ent.name === '.bin') continue
stack.push(full)
}
}
}
// Drop GUI sourcemap unless kept
if (process.env.PEARDOCK_KEEP_SOURCEMAP !== '1') {
rm('electron/app.bundle.cjs.map')
}
console.log(
`[forge] packageAfterCopy target=${packageHost} stripped=${removed} in ${Date.now() - t0}ms`
)
}
module.exports = { module.exports = {
packagerConfig: { packagerConfig: {
name: appName, name: appName,
@@ -162,6 +314,14 @@ module.exports = {
hooks: { hooks: {
prePackage: async () => { prePackage: async () => {
console.log(`[forge] packaging target host: ${packageHost}`)
if (process.env.PEARDOCK_SKIP_PREPACKAGE_BUNDLE === '1') {
const bundle = path.join(__dirname, 'electron', 'app.bundle.cjs')
if (fs.existsSync(bundle)) {
console.log('[forge] prePackage: skip bundle (already built)')
return
}
}
require('child_process').execFileSync( require('child_process').execFileSync(
process.execPath, process.execPath,
[path.join(__dirname, 'scripts', 'build-client-bundle.cjs')], [path.join(__dirname, 'scripts', 'build-client-bundle.cjs')],
@@ -177,30 +337,23 @@ module.exports = {
appPkg.main = 'electron/main.cjs' appPkg.main = 'electron/main.cjs'
delete appPkg.devDependencies delete appPkg.devDependencies
delete appPkg.scripts delete appPkg.scripts
// Drop server-only deps from package.json so nothing tries to resolve them
if (appPkg.dependencies) {
for (const name of [
'dockerode',
'docker-modem',
'ssh2',
'bare-build',
'bare-runtime',
'bare-sqlite',
'pear-electron',
]) {
delete appPkg.dependencies[name]
}
}
fs.writeFileSync(pkgPath, JSON.stringify(appPkg, null, 2) + '\n') fs.writeFileSync(pkgPath, JSON.stringify(appPkg, null, 2) + '\n')
const junk = [ stripBuildPath(buildPath)
'node_modules/bare-sidecar',
'node_modules/electron',
'node_modules/@electron-forge',
'node_modules/bare-build',
'node_modules/bare-runtime',
'node_modules/pear-runtime',
'node_modules/esbuild',
'server',
'scripts',
'out',
'test',
'docs',
]
for (const rel of junk) {
const p = path.join(buildPath, rel)
try {
fs.rmSync(p, { recursive: true, force: true })
} catch {
// ignore
}
}
}, },
/** /**
* Always re-sign darwin builds after packager rewrites the Electron .app. * Always re-sign darwin builds after packager rewrites the Electron .app.
+20 -4
View File
@@ -15,7 +15,12 @@
const path = require('path') const path = require('path')
const { spawnSync } = require('child_process') const { spawnSync } = require('child_process')
const { ALL_64, parseHostList, clientNpmScript } = require('./hosts.cjs') const {
ALL_64,
parseHostList,
clientNpmScript,
hostToElectron,
} = require('./hosts.cjs')
const root = path.resolve(__dirname, '..') const root = path.resolve(__dirname, '..')
@@ -24,6 +29,7 @@ const CLIENT_HOSTS = parseHostList(process.env.PEARDOCK_CLIENT_HOSTS, ALL_64)
function run(cmd, args, opts = {}) { function run(cmd, args, opts = {}) {
console.log(`\n$ ${cmd} ${args.join(' ')}\n`) console.log(`\n$ ${cmd} ${args.join(' ')}\n`)
const t0 = Date.now()
const res = spawnSync(cmd, args, { const res = spawnSync(cmd, args, {
cwd: root, cwd: root,
stdio: 'inherit', stdio: 'inherit',
@@ -33,10 +39,13 @@ function run(cmd, args, opts = {}) {
}) })
if (res.error) throw res.error if (res.error) throw res.error
if (res.status !== 0) process.exit(res.status || 1) if (res.status !== 0) process.exit(res.status || 1)
console.log(`[make] ok in ${((Date.now() - t0) / 1000).toFixed(1)}s`)
} }
function npmRun(script) { function npmRun(script, env) {
run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', script]) run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', script], {
env: env ? { ...process.env, ...env } : process.env,
})
} }
function makeServer(hosts = SERVER_HOSTS) { function makeServer(hosts = SERVER_HOSTS) {
@@ -54,6 +63,7 @@ function makeServer(hosts = SERVER_HOSTS) {
function makeClient(hosts = CLIENT_HOSTS) { function makeClient(hosts = CLIENT_HOSTS) {
console.log(`[make] client hosts: ${hosts.join(', ')}`) console.log(`[make] client hosts: ${hosts.join(', ')}`)
// Build GUI bundle once; forge prePackage skips rebuild when this is set
npmRun('build:client-bundle') npmRun('build:client-bundle')
for (const host of hosts) { for (const host of hosts) {
const script = clientNpmScript(host) const script = clientNpmScript(host)
@@ -61,11 +71,17 @@ function makeClient(hosts = CLIENT_HOSTS) {
console.error(`[make] no client script for host ${host}`) console.error(`[make] no client script for host ${host}`)
process.exit(1) process.exit(1)
} }
// Prefer prebuilds; skip native rebuild (cross-arch rebuild is slow/fails) const { platform, arch } = hostToElectron(host)
console.log(`[make] client ${host} (filter prebuilds to ${platform}-${arch})`)
// Prefer prebuilds; skip native rebuild (cross-arch rebuild is slow/fails).
// PEARDOCK_PACKAGE_* tells forge.config to ignore foreign prebuilds (~500MB → ~30MB).
const env = { const env = {
...process.env, ...process.env,
npm_config_build_from_source: 'false', npm_config_build_from_source: 'false',
PEARDOCK_SKIP_REBUILD: process.env.PEARDOCK_SKIP_REBUILD || '1', PEARDOCK_SKIP_REBUILD: process.env.PEARDOCK_SKIP_REBUILD || '1',
PEARDOCK_PACKAGE_PLATFORM: platform,
PEARDOCK_PACKAGE_ARCH: arch,
PEARDOCK_SKIP_PREPACKAGE_BUNDLE: '1',
} }
run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', script], { env }) run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', script], { env })
} }