Fix hung Electron client packages on cross-arch (linux-arm64)
Release rolling / release (push) Failing after 3m18s
Release rolling / release (push) Failing after 3m18s
rebuildConfig:false still ran @electron/rebuild (spread of false is a no-op), which can stall for many minutes when packaging arm64 on x64 CI under "Finalizing package". Use onlyModules:[] to truly skip rebuilds, pre-download Electron zips with timeouts before forge package, point packager at electronZipDir, and kill any single host package after 8m.
This commit is contained in:
@@ -158,14 +158,17 @@ jobs:
|
||||
PEARDOCK_CLIENT_HOSTS: ${{ github.event.inputs.hosts || 'linux-x64,linux-arm64,darwin-x64,darwin-arm64,win32-x64,win32-arm64' }}
|
||||
PEARDOCK_SKIP_CLIENT: ${{ github.event.inputs.skip_client == 'true' && '1' || '0' }}
|
||||
RELEASE_TAG: rolling
|
||||
# Prefer prebuilds; skip electron-rebuild (main "Finalizing package" stall)
|
||||
# Prefer prebuilds; skip electron-rebuild (onlyModules:[] in forge.config)
|
||||
npm_config_build_from_source: 'false'
|
||||
PEARDOCK_SKIP_REBUILD: '1'
|
||||
# Reuse Electron downloads across the 6 client hosts
|
||||
# Reuse Electron downloads; make.cjs predownloads all host zips first
|
||||
ELECTRON_CACHE: ${{ github.workspace }}/.cache/electron
|
||||
electron_config_cache: ${{ github.workspace }}/.cache/electron
|
||||
# electron-packager / forge download target Electron builds
|
||||
ELECTRON_GET_USE_PROXY: ${{ env.ELECTRON_GET_USE_PROXY || '' }}
|
||||
PEARDOCK_ELECTRON_DOWNLOAD_TIMEOUT_MS: '180000'
|
||||
# Kill a single hung forge package (default 10m) so the job fails visibly
|
||||
PEARDOCK_CLIENT_TIMEOUT_MS: '480000'
|
||||
ELECTRON_GET_USE_PROXY: '0'
|
||||
CI: 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||
@@ -180,9 +183,10 @@ jobs:
|
||||
export GITEA_URL="${GITHUB_SERVER_URL:-}"
|
||||
fi
|
||||
mkdir -p "${ELECTRON_CACHE:-$GITHUB_WORKSPACE/.cache/electron}"
|
||||
mkdir -p "$GITHUB_WORKSPACE/.cache/electron-zips"
|
||||
echo "Server hosts: $PEARDOCK_SERVER_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 scripts/predownload-electron.cjs
|
||||
bash scripts/gitea-rolling-release.sh
|
||||
|
||||
- name: Upload dist as workflow artifact (backup)
|
||||
|
||||
@@ -13,3 +13,6 @@ peardock-peers.json
|
||||
peardock-sbom.json
|
||||
peardock-vault.json
|
||||
server/.env
|
||||
|
||||
# Local / CI electron zip cache
|
||||
.cache/
|
||||
|
||||
+41
-5
@@ -56,6 +56,7 @@ const IGNORE_PREFIXES = [
|
||||
'/workers',
|
||||
'/peardock-branding', // master brand package; runtime copies live in build/ + assets/
|
||||
'/tools', // CI-only binaries (rcodesign, etc.)
|
||||
'/.cache', // electron zip predownload cache
|
||||
'/build/stubs',
|
||||
'/build/shims',
|
||||
'/ROADMAP.md',
|
||||
@@ -164,11 +165,22 @@ function shouldIgnore(file) {
|
||||
return false
|
||||
}
|
||||
|
||||
/** Skip electron-rebuild when prebuilds exist (default in CI). Set PEARDOCK_FORCE_REBUILD=1 to rebuild. */
|
||||
/**
|
||||
* Skip @electron/rebuild work during package.
|
||||
* NOTE: rebuildConfig:false still *invokes* rebuild (spread of false is a no-op).
|
||||
* onlyModules: [] is the real "rebuild nothing" switch — critical for cross-arch
|
||||
* CI (linux-x64 packaging linux-arm64) where rebuild can hang for many minutes.
|
||||
* Set PEARDOCK_FORCE_REBUILD=1 to rebuild listed modules.
|
||||
*/
|
||||
const skipRebuild =
|
||||
process.env.PEARDOCK_FORCE_REBUILD !== '1' &&
|
||||
process.env.PEARDOCK_SKIP_REBUILD !== '0'
|
||||
|
||||
const electronZipDir = path.join(__dirname, '.cache', 'electron-zips')
|
||||
const useElectronZipDir =
|
||||
process.env.PEARDOCK_USE_ELECTRON_ZIP_DIR !== '0' &&
|
||||
fs.existsSync(electronZipDir)
|
||||
|
||||
/**
|
||||
* After copy (before asar): belt-and-suspenders strip of foreign prebuilds + junk.
|
||||
* Ignore should already exclude these; this catches nested paths packager still copied.
|
||||
@@ -208,17 +220,28 @@ function stripBuildPath(buildPath) {
|
||||
'test',
|
||||
'docs',
|
||||
'peardock-branding',
|
||||
'tools',
|
||||
'.cache',
|
||||
'ROADMAP.md',
|
||||
'README.md',
|
||||
]
|
||||
for (const rel of junk) rm(rel)
|
||||
|
||||
// Walk node_modules for prebuilds/<host> dirs
|
||||
// Walk node_modules for prebuilds/<host> dirs (no symlink follow — avoid cycles)
|
||||
const nm = path.join(buildPath, 'node_modules')
|
||||
if (fs.existsSync(nm)) {
|
||||
const stack = [nm]
|
||||
const seen = new Set()
|
||||
while (stack.length) {
|
||||
const dir = stack.pop()
|
||||
let real
|
||||
try {
|
||||
real = fs.realpathSync(dir)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (seen.has(real)) continue
|
||||
seen.add(real)
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
@@ -227,6 +250,8 @@ function stripBuildPath(buildPath) {
|
||||
}
|
||||
for (const ent of entries) {
|
||||
const full = path.join(dir, ent.name)
|
||||
// Skip symlinks entirely (deps sometimes link nested trees)
|
||||
if (ent.isSymbolicLink()) continue
|
||||
if (!ent.isDirectory()) continue
|
||||
if (ent.name === 'prebuilds') {
|
||||
let hosts
|
||||
@@ -246,7 +271,6 @@ function stripBuildPath(buildPath) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Do not descend into prebuilds we already handled
|
||||
if (ent.name === '.bin') continue
|
||||
stack.push(full)
|
||||
}
|
||||
@@ -280,15 +304,21 @@ module.exports = {
|
||||
// Symlink deref + prune walk the whole graph and dominate finalize time
|
||||
derefSymlinks: false,
|
||||
prune: false,
|
||||
// Prefer pre-downloaded zips (scripts/predownload-electron.cjs) so package
|
||||
// never blocks on a silent cross-arch Electron download mid-finalize.
|
||||
...(useElectronZipDir ? { electronZipDir } : {}),
|
||||
// CI: surface packager progress (downloads, asar) instead of quiet hangs
|
||||
quiet: process.env.CI ? false : true,
|
||||
// Signing is done in postPackage via scripts/sign-macos-app.cjs (ad-hoc or
|
||||
// Developer ID). Leaving packager osxSign off avoids double-sign races;
|
||||
// a partial linker-signed Electron binary is what causes "damaged / Trash".
|
||||
osxSign: false,
|
||||
},
|
||||
|
||||
// false = skip @electron/rebuild entirely (use prebuilds). Huge CI win.
|
||||
// onlyModules:[] = rebuild zero modules (forge still calls rebuild, but it
|
||||
// no-ops). rebuildConfig:false does NOT skip the rebuild step.
|
||||
rebuildConfig: skipRebuild
|
||||
? false
|
||||
? { onlyModules: [], force: false }
|
||||
: {
|
||||
force: false,
|
||||
onlyModules: [
|
||||
@@ -316,6 +346,12 @@ module.exports = {
|
||||
hooks: {
|
||||
prePackage: async () => {
|
||||
console.log(`[forge] packaging target host: ${packageHost}`)
|
||||
console.log(
|
||||
`[forge] electronZipDir: ${useElectronZipDir ? electronZipDir : '(none — packager will download)'}`
|
||||
)
|
||||
console.log(
|
||||
`[forge] rebuild: ${skipRebuild ? 'skip (onlyModules:[])' : 'enabled'}`
|
||||
)
|
||||
if (process.env.PEARDOCK_SKIP_PREPACKAGE_BUNDLE === '1') {
|
||||
const bundle = path.join(__dirname, 'electron', 'app.bundle.cjs')
|
||||
if (fs.existsSync(bundle)) {
|
||||
|
||||
+57
-1
@@ -10,6 +10,8 @@
|
||||
* Env:
|
||||
* PEARDOCK_SERVER_HOSTS=linux-x64,darwin-arm64
|
||||
* PEARDOCK_CLIENT_HOSTS=linux-x64,win32-x64
|
||||
* PEARDOCK_CLIENT_TIMEOUT_MS=600000 # kill hung forge package (default 10m)
|
||||
* PEARDOCK_SKIP_ELECTRON_PREDOWNLOAD=1
|
||||
*/
|
||||
'use strict'
|
||||
|
||||
@@ -26,6 +28,7 @@ const root = path.resolve(__dirname, '..')
|
||||
|
||||
const SERVER_HOSTS = parseHostList(process.env.PEARDOCK_SERVER_HOSTS, ALL_64)
|
||||
const CLIENT_HOSTS = parseHostList(process.env.PEARDOCK_CLIENT_HOSTS, ALL_64)
|
||||
const CLIENT_TIMEOUT_MS = Number(process.env.PEARDOCK_CLIENT_TIMEOUT_MS || 600_000)
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
console.log(`\n$ ${cmd} ${args.join(' ')}\n`)
|
||||
@@ -61,10 +64,30 @@ function makeServer(hosts = SERVER_HOSTS) {
|
||||
}
|
||||
}
|
||||
|
||||
function predownloadElectron(hosts) {
|
||||
if (process.env.PEARDOCK_SKIP_ELECTRON_PREDOWNLOAD === '1') {
|
||||
console.log('[make] skip electron predownload (PEARDOCK_SKIP_ELECTRON_PREDOWNLOAD=1)')
|
||||
return
|
||||
}
|
||||
console.log('[make] pre-downloading Electron for client hosts…')
|
||||
run(
|
||||
process.execPath,
|
||||
[path.join(root, 'scripts', 'predownload-electron.cjs')],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
PEARDOCK_CLIENT_HOSTS: hosts.join(','),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function makeClient(hosts = CLIENT_HOSTS) {
|
||||
console.log(`[make] client hosts: ${hosts.join(', ')}`)
|
||||
// Build GUI bundle once; forge prePackage skips rebuild when this is set
|
||||
npmRun('build:client-bundle')
|
||||
predownloadElectron(hosts)
|
||||
|
||||
for (const host of hosts) {
|
||||
const script = clientNpmScript(host)
|
||||
if (!script) {
|
||||
@@ -82,8 +105,41 @@ function makeClient(hosts = CLIENT_HOSTS) {
|
||||
PEARDOCK_PACKAGE_PLATFORM: platform,
|
||||
PEARDOCK_PACKAGE_ARCH: arch,
|
||||
PEARDOCK_SKIP_PREPACKAGE_BUNDLE: '1',
|
||||
// Surface packager/forge progress in CI logs
|
||||
DEBUG: process.env.DEBUG || (process.env.CI ? 'electron-packager,electron-forge:lifecycle' : ''),
|
||||
}
|
||||
run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', script], { env })
|
||||
|
||||
console.log(
|
||||
`[make] client timeout: ${(CLIENT_TIMEOUT_MS / 1000).toFixed(0)}s (PEARDOCK_CLIENT_TIMEOUT_MS)`
|
||||
)
|
||||
const t0 = Date.now()
|
||||
const res = spawnSync(
|
||||
process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
||||
['run', script],
|
||||
{
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
shell: process.platform === 'win32',
|
||||
timeout: CLIENT_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
}
|
||||
)
|
||||
if (res.error) {
|
||||
if (res.error.code === 'ETIMEDOUT') {
|
||||
console.error(
|
||||
`[make] FATAL: client package ${host} exceeded ${CLIENT_TIMEOUT_MS}ms — killed. ` +
|
||||
`Often a hung @electron/rebuild or Electron download. Check logs above.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
throw res.error
|
||||
}
|
||||
if (res.status !== 0) {
|
||||
console.error(`[make] client package ${host} failed with status ${res.status}`)
|
||||
process.exit(res.status || 1)
|
||||
}
|
||||
console.log(`[make] ok ${host} in ${((Date.now() - t0) / 1000).toFixed(1)}s`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Pre-download Electron binaries for every client host so forge packaging
|
||||
* does not silently hang mid-package on cross-arch GitHub downloads.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/predownload-electron.cjs
|
||||
* PEARDOCK_CLIENT_HOSTS=linux-x64,darwin-arm64 node scripts/predownload-electron.cjs
|
||||
*
|
||||
* Env:
|
||||
* ELECTRON_CACHE / electron_config_cache — cache dir (recommended in CI)
|
||||
* PEARDOCK_ELECTRON_DOWNLOAD_TIMEOUT_MS — per-artifact timeout (default 180000)
|
||||
*/
|
||||
'use strict'
|
||||
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const { downloadArtifact } = require('@electron/get')
|
||||
const { ALL_64, parseHostList, hostToElectron } = require('./hosts.cjs')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const electronVersion = require(path.join(root, 'node_modules/electron/package.json')).version
|
||||
const hosts = parseHostList(process.env.PEARDOCK_CLIENT_HOSTS, ALL_64)
|
||||
const timeoutMs = Number(process.env.PEARDOCK_ELECTRON_DOWNLOAD_TIMEOUT_MS || 180000)
|
||||
const zipDir = path.join(root, '.cache', 'electron-zips')
|
||||
|
||||
function withTimeout(promise, ms, label) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => {
|
||||
reject(new Error(`[predownload-electron] timeout after ${ms}ms: ${label}`))
|
||||
}, ms)
|
||||
promise.then(
|
||||
(v) => {
|
||||
clearTimeout(t)
|
||||
resolve(v)
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(t)
|
||||
reject(e)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(zipDir, { recursive: true })
|
||||
if (process.env.ELECTRON_CACHE || process.env.electron_config_cache) {
|
||||
console.log(
|
||||
'[predownload-electron] cache:',
|
||||
process.env.ELECTRON_CACHE || process.env.electron_config_cache
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
`[predownload-electron] electron@${electronVersion} hosts: ${hosts.join(', ')}`
|
||||
)
|
||||
|
||||
for (const host of hosts) {
|
||||
const { platform, arch } = hostToElectron(host)
|
||||
const label = `${platform}-${arch}`
|
||||
const destName = `electron-v${electronVersion}-${platform}-${arch}.zip`
|
||||
const destPath = path.join(zipDir, destName)
|
||||
if (fs.existsSync(destPath) && fs.statSync(destPath).size > 1_000_000) {
|
||||
console.log(`[predownload-electron] skip (present): ${destName}`)
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(`[predownload-electron] downloading ${label}…`)
|
||||
const t0 = Date.now()
|
||||
const zipPath = await withTimeout(
|
||||
downloadArtifact({
|
||||
version: electronVersion,
|
||||
platform,
|
||||
arch,
|
||||
artifactName: 'electron',
|
||||
}),
|
||||
timeoutMs,
|
||||
label
|
||||
)
|
||||
// Copy into a stable electronZipDir layout for packager
|
||||
fs.copyFileSync(zipPath, destPath)
|
||||
const mb = (fs.statSync(destPath).size / 1024 / 1024).toFixed(1)
|
||||
console.log(
|
||||
`[predownload-electron] ok ${label} → ${destName} (${mb} MB) in ${((Date.now() - t0) / 1000).toFixed(1)}s`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[predownload-electron] zip dir: ${zipDir}`)
|
||||
console.log('[predownload-electron] done')
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user