diff --git a/.gitea/workflows/release-rolling.yml b/.gitea/workflows/release-rolling.yml index c3eb70f..4797a12 100644 --- a/.gitea/workflows/release-rolling.yml +++ b/.gitea/workflows/release-rolling.yml @@ -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) diff --git a/.gitignore b/.gitignore index 2d6ac70..1de4da2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ peardock-peers.json peardock-sbom.json peardock-vault.json server/.env + +# Local / CI electron zip cache +.cache/ diff --git a/forge.config.cjs b/forge.config.cjs index 0f4704e..a74a13f 100644 --- a/forge.config.cjs +++ b/forge.config.cjs @@ -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/ dirs + // Walk node_modules for prebuilds/ 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)) { diff --git a/scripts/make.cjs b/scripts/make.cjs index 807a1f8..c72eb6a 100755 --- a/scripts/make.cjs +++ b/scripts/make.cjs @@ -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`) } } diff --git a/scripts/predownload-electron.cjs b/scripts/predownload-electron.cjs new file mode 100644 index 0000000..6fbb30b --- /dev/null +++ b/scripts/predownload-electron.cjs @@ -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) +})