Build Electron Desktop App #309
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build Electron Desktop App | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: "Release version (e.g., v1.6.8)" | |
| required: true | |
| type: string | |
| publish_npm: | |
| description: "Also run the npm publish leg (turn off when re-attaching desktop assets to a release whose npm package already shipped)" | |
| required: false | |
| default: true | |
| type: boolean | |
| build_ref: | |
| description: "Git ref to BUILD from (default: the version tag). Set to a branch when the tag itself cannot build — e.g. a lockfile that was already broken when it was cut — and the assets must come from the repaired line" | |
| required: false | |
| default: "" | |
| type: string | |
| # Least-privilege default: read-only at the top level; each job grants the writes it | |
| # needs (build/release upload assets, publish-npm forwards npm provenance / packages | |
| # to the reusable workflow) — Scorecard TokenPermissions. | |
| permissions: | |
| contents: read | |
| jobs: | |
| validate: | |
| name: Validate version | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| version: ${{ steps.validate.outputs.version }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| fetch-depth: 0 | |
| - name: Validate version format | |
| id: validate | |
| env: | |
| # Pass workflow context via env (never interpolate ${{ ... }} straight | |
| # into the run: script body) so the shell receives variables, not | |
| # inlined text — zizmor template-injection mitigation. INPUT_VERSION is | |
| # the operator-supplied value and is regex-validated below before use. | |
| EVENT_NAME: ${{ github.event_name }} | |
| INPUT_VERSION: ${{ inputs.version }} | |
| run: | | |
| if [[ "$EVENT_NAME" == "push" ]]; then | |
| VERSION="${GITHUB_REF#refs/tags/}" | |
| else | |
| VERSION="$INPUT_VERSION" | |
| fi | |
| if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "Error: Invalid version format. Expected: v1.6.8" | |
| exit 1 | |
| fi | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "✓ Valid version: $VERSION" | |
| web-build: | |
| name: Build shared Next standalone | |
| needs: validate | |
| # Stage 8 (issue #10321): the four desktop legs used to each run the full | |
| # `npm run build` (Next standalone) — ~111 runner-minutes per release just to | |
| # produce the same platform-independent bundle four times. This job builds it | |
| # once on ubuntu; every leg then restores the byte-verified archive and | |
| # re-forks its native optionals (scripts/build/standaloneBundle.mjs). | |
| # | |
| # Rollback lever: set the repo variable ELECTRON_SHARED_STANDALONE=disabled. | |
| # This job then skips, every leg falls back to building its own web bundle | |
| # (the legacy step below), and the pipeline behaves exactly like pre-Stage 8 — | |
| # no revert needed. | |
| if: ${{ !cancelled() && needs.validate.result == 'success' && vars.ELECTRON_SHARED_STANDALONE != 'disabled' }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| # workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a | |
| # tag push this resolves to the same commit. | |
| ref: ${{ inputs.build_ref || needs.validate.outputs.version }} | |
| - name: Setup Node | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: 24 | |
| - name: Install dependencies | |
| run: npm ci | |
| env: | |
| NPM_CONFIG_LEGACY_PEER_DEPS: true | |
| - name: Build Next.js standalone | |
| # webpack, not Turbopack, for the same hosted-runner RAM reason as the | |
| # linux leg (see the long comment on the fallback step in `build`). | |
| env: | |
| JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation | |
| NODE_OPTIONS: "--max_old_space_size=6144" | |
| OMNIROUTE_USE_TURBOPACK: "0" | |
| run: npm run build | |
| - name: Pack standalone bundle | |
| # Deterministic tar.gz + byte-level manifest; the manifest embeds the | |
| # archive's own sha256 so artifact-transfer corruption is caught before | |
| # extraction, and every entry is re-verified after extraction. | |
| run: node scripts/build/standaloneBundle.mjs pack --out web-bundle.tar.gz | |
| - name: Upload shared web bundle | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: web-standalone-bundle | |
| # compression-level 0: the payload is already a deterministic tar.gz; | |
| # re-zipping would only burn runner CPU without shrinking it further. | |
| compression-level: 0 | |
| # Legs consume this within minutes; no reason to retain it like the | |
| # installer artifacts (default 90d). | |
| retention-days: 3 | |
| path: | | |
| web-bundle.tar.gz | |
| web-bundle.tar.gz.manifest.json | |
| build: | |
| name: Build Electron (${{ matrix.platform }}) | |
| needs: [validate, web-build] | |
| # `web-build` is skipped when ELECTRON_SHARED_STANDALONE=disabled (rollback | |
| # mode); legs then run the legacy per-leg web build below. If it ran and | |
| # failed, fail closed: legs cannot package without the bundle, and silently | |
| # falling back to four per-leg builds would hide exactly the regression the | |
| # shared job exists to surface. | |
| if: ${{ !cancelled() && needs.validate.result == 'success' && (needs.web-build.result == 'success' || needs.web-build.result == 'skipped') }} | |
| runs-on: ${{ matrix.runner }} | |
| permissions: | |
| contents: write # electron-builder may publish artifacts with GH_TOKEN | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - platform: windows | |
| runner: windows-latest | |
| target: win | |
| ext: .exe | |
| os: win32 | |
| arch: x64 | |
| - platform: macos-intel | |
| runner: macos-15-intel | |
| target: mac-x64 | |
| ext: .dmg | |
| os: darwin | |
| arch: x64 | |
| - platform: macos-arm64 | |
| runner: macos-latest | |
| target: mac-arm64 | |
| ext: -arm64.dmg | |
| os: darwin | |
| arch: arm64 | |
| - platform: linux | |
| runner: ubuntu-latest | |
| target: linux | |
| ext: .AppImage | |
| deb_ext: .deb | |
| os: linux | |
| arch: x64,arm64 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| # workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a | |
| # tag push this resolves to the same commit. | |
| ref: ${{ inputs.build_ref || needs.validate.outputs.version }} | |
| - name: Setup Node | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: 24 | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| env: | |
| NPM_CONFIG_LEGACY_PEER_DEPS: true | |
| - name: Sanitize Windows home directory | |
| if: runner.os == 'Windows' | |
| shell: bash | |
| run: | | |
| # The default USERPROFILE contains junction points (Application Data) | |
| # that cause EPERM errors during Next.js standalone build glob scans. | |
| # Create a clean temp profile directory to avoid this. | |
| mkdir -p "$RUNNER_TEMP/home" | |
| echo "USERPROFILE=$RUNNER_TEMP/home" >> "$GITHUB_ENV" | |
| - name: Build Next.js standalone (legacy per-leg fallback) | |
| # Stage 8: only runs in rollback mode (ELECTRON_SHARED_STANDALONE=disabled) | |
| # or when the shared web-build job was skipped. Otherwise the leg restores | |
| # the shared bundle from the `web-build` job below. | |
| if: needs.web-build.result == 'skipped' | |
| env: | |
| JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation | |
| NODE_OPTIONS: "--max_old_space_size=6144" | |
| # Linux builds with webpack, not Turbopack. Turbopack's production build | |
| # allocates natively (Rust, off the V8 heap), so --max_old_space_size does | |
| # not bound it, and on this module graph it peaks above what the hosted | |
| # runner can give — the VM is reclaimed mid-compile with "The runner has | |
| # received a shutdown signal", no exit code. That is what silently took the | |
| # whole desktop channel out of v3.8.49: the linux leg died, `release` was | |
| # skipped, and the release shipped with ZERO assets. Measured on a 32 GB | |
| # box the same build passes and peaks past 14 GB. The webpack fallback is | |
| # the project's documented escape hatch for RAM-constrained machines | |
| # (docs/reference/ENVIRONMENT.md, #6409) and is the same remedy already | |
| # applied to nightly-compat's Node 26 build (#8090). | |
| OMNIROUTE_USE_TURBOPACK: ${{ matrix.platform == 'linux' && '0' || '1' }} | |
| run: npm run build | |
| - name: Download shared web bundle | |
| # Stage 8: inverse of the fallback step above — runs exactly when the | |
| # shared `web-build` job produced the bundle. | |
| if: needs.web-build.result == 'success' | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: web-standalone-bundle | |
| - name: Restore + hydrate shared web bundle | |
| if: needs.web-build.result == 'success' | |
| shell: bash | |
| # restore: verify the archive's sha256 against the manifest, extract, then | |
| # re-verify every entry (existence + size + content hash + symlink | |
| # targets, and no unlisted files) byte-for-byte. | |
| # hydrate: the bundle was built on ubuntu, so install-machine-forked native | |
| # optionals (@img/sharp-*, @img/sharp-libvips-*, @ngrok/ngrok-*, | |
| # fsevents) carry linux forks. Replace them with the forks this | |
| # leg's own `npm ci` resolved, then assert every bundled native | |
| # (koffi triplets, better-sqlite3 prebuilds, wreq-js, onnxruntime) | |
| # can service this leg's platform/arch before packaging starts. | |
| run: | | |
| node scripts/build/standaloneBundle.mjs restore --archive web-bundle.tar.gz | |
| node scripts/build/standaloneBundle.mjs hydrate --platform ${{ matrix.os }} --arch ${{ matrix.arch }} | |
| - name: Sync version in electron/package.json | |
| shell: bash | |
| env: | |
| # Pass the validated version via env (never interpolate ${{ ... }} | |
| # straight into the run: script body) — zizmor template-injection | |
| # mitigation. Already regex-validated (^v[0-9]+\.[0-9]+\.[0-9]+$) in | |
| # the `validate` job, so it cannot carry shell metacharacters. | |
| VERSION: ${{ needs.validate.outputs.version }} | |
| run: | | |
| VERSION_NO_V="${VERSION#v}" | |
| node -e " | |
| const fs = require('fs'); | |
| const pkg = JSON.parse(fs.readFileSync('electron/package.json')); | |
| pkg.version = '$VERSION_NO_V'; | |
| fs.writeFileSync('electron/package.json', JSON.stringify(pkg, null, 2) + '\\n'); | |
| " | |
| echo "✓ electron/package.json version set to $VERSION_NO_V" | |
| - name: Install fpm (Linux .deb packaging tool) | |
| if: matrix.platform == 'linux' | |
| run: sudo gem install fpm --no-document | |
| - name: Install Electron dependencies | |
| working-directory: electron | |
| run: npm ci --no-audit --no-fund | |
| - name: Build Electron for ${{ matrix.platform }} | |
| working-directory: electron | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: npm run build:${{ matrix.target }} | |
| - name: Smoke packaged Electron app | |
| if: matrix.platform != 'linux' | |
| # Best-effort smoke on Windows + macos-arm64: | |
| # - Windows: requestSingleInstanceLock() fails due to USERPROFILE | |
| # sanitization needed for the build step. | |
| # - macos-arm64: the headless GitHub arm64 runner crashes Electron's GPU | |
| # process (gpu_process_host exit_code=15 → network service crash → | |
| # "No rendezvous client, terminating process"), so the app can't bind | |
| # 127.0.0.1:20128 in time. The identical bundle is smoke-gated on | |
| # macos-intel + linux, so packaging is still verified per-OS; we don't | |
| # let the arm64 runner's GPU flakiness block the desktop release. | |
| continue-on-error: ${{ matrix.platform == 'windows' || matrix.platform == 'macos-arm64' }} | |
| env: | |
| ELECTRON_SMOKE_TIMEOUT_MS: 60000 | |
| ELECTRON_SMOKE_STREAM_LOGS: "1" | |
| run: npm run electron:smoke:packaged | |
| - name: Smoke packaged Electron app (Linux) | |
| if: matrix.platform == 'linux' | |
| # #7592: also cold-restart against the same DATA_DIR and assert a | |
| # native SQLite driver (not the sql.js WASM fallback) is selected on | |
| # the second launch — blocking here since Linux has no Windows-style | |
| # sandbox caveats that would make it flaky. | |
| env: | |
| ELECTRON_SMOKE_TIMEOUT_MS: 60000 | |
| ELECTRON_SMOKE_STREAM_LOGS: "1" | |
| ELECTRON_SMOKE_COLD_RESTART: "1" | |
| run: xvfb-run -a npm run electron:smoke:packaged | |
| - name: Collect installers | |
| shell: bash | |
| run: | | |
| mkdir -p release-assets | |
| cd electron/dist-electron | |
| # Copy only installer files for this platform | |
| for file in *${{ matrix.ext }}; do | |
| [ -f "$file" ] && cp "$file" ../../release-assets/ | |
| done | |
| # Linux: also copy .deb package | |
| if [ "${{ matrix.platform }}" = "linux" ]; then | |
| for file in *.deb; do | |
| [ -f "$file" ] && cp "$file" ../../release-assets/ | |
| done | |
| fi | |
| # Windows: also copy portable standalone exe as OmniRoute.exe | |
| if [ "${{ matrix.platform }}" = "windows" ]; then | |
| for file in *.exe; do | |
| # Skip the NSIS installer (contains "Setup") | |
| case "$file" in *Setup*) continue ;; esac | |
| [ -f "$file" ] && cp "$file" "../../release-assets/OmniRoute.exe" && break | |
| done | |
| fi | |
| # electron-updater manifests (latest.yml / latest-mac.yml / latest-linux.yml) | |
| # must be published alongside the installers, or autoUpdater fails with | |
| # "Cannot find latest.yml in the latest release artifacts" (#6766). | |
| for file in latest*.yml; do | |
| [ -f "$file" ] && cp "$file" ../../release-assets/ | |
| done | |
| - name: Upload artifacts | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: electron-${{ matrix.platform }} | |
| path: release-assets/ | |
| release: | |
| name: Create Release | |
| needs: [validate, build] | |
| # Fail-partial, not fail-closed. `build` is a 4-leg matrix with `fail-fast: false`, | |
| # so the legs that succeed still upload their artifacts — but a default `needs:` | |
| # gate skips this job the moment ANY leg fails, discarding all of them. That is | |
| # exactly what happened to v3.8.49: the linux leg died and the release shipped with | |
| # ZERO assets, throwing away 1.7 GB of good Windows/macOS installers **and** the | |
| # source archives + SBOM, which do not depend on a build at all. The result was | |
| # indistinguishable from "this version has no desktop channel". | |
| # Now: attach everything that did build, then fail the job loudly (see the last | |
| # step) so an incomplete channel is visible instead of silent. | |
| if: ${{ !cancelled() && needs.validate.result == 'success' }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write # softprops/action-gh-release creates the GitHub Release | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| fetch-depth: 0 | |
| # Source archives + SBOM come from the tag being released, not the dispatching branch. | |
| ref: ${{ inputs.build_ref || needs.validate.outputs.version }} | |
| # `merge-multiple` is deliberately OFF. It resolves same-name collisions by ARRIVAL | |
| # ORDER, and the two macOS jobs each emit their own `latest-mac.yml` listing only their | |
| # own dmg (measured: 338 and 350 bytes, different content, identical name). One silently | |
| # overwrote the other — arm64 won in the published v3.8.48, and since the Intel dmg | |
| # carries no arch suffix in its name, electron-updater's | |
| # `files.find(url includes process.arch) ?? files.shift()` sends every Intel Mac to the | |
| # ARM dmg. Downloading into per-artifact subdirectories keeps both, so they can be | |
| # merged on purpose instead of by luck. | |
| - name: Download all artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| path: artifacts | |
| # Writes release-assets/latest-mac.yml with BOTH dmgs, un-suffixed entry first (that is | |
| # the one electron-updater can only reach through its fallback). Refuses to write when the | |
| # inputs disagree on version — a manifest stitched from two builds is worse than none. | |
| - name: Merge the per-arch macOS updater manifests | |
| run: node scripts/release/merge-mac-update-manifest.mjs artifacts release-assets | |
| # Everything else moves across as-is. The partial latest-mac.yml files are excluded so | |
| # they cannot clobber the merged one; -n is a second belt on the same braces. | |
| - name: Collect the remaining artifacts | |
| run: | | |
| mkdir -p release-assets | |
| find artifacts -type f ! -name latest-mac.yml -exec cp -n {} release-assets/ \; | |
| echo "release-assets:" | |
| ls -la release-assets/ | |
| - name: Create source archives | |
| env: | |
| # Pass the validated version via env (never interpolate ${{ ... }} | |
| # straight into the run: script body) — zizmor template-injection | |
| # mitigation. Already regex-validated (^v[0-9]+\.[0-9]+\.[0-9]+$) in | |
| # the `validate` job, so it cannot carry shell metacharacters. | |
| VERSION: ${{ needs.validate.outputs.version }} | |
| run: | | |
| # Create source code archives (excluding dev dependencies and build artifacts) | |
| export TARBALL="OmniRoute-${VERSION}.source.tar.gz" | |
| export ZIPBALL="OmniRoute-${VERSION}.source.zip" | |
| # Use git archive for clean source export | |
| git archive --format=tar.gz --prefix="OmniRoute-${VERSION}/" HEAD -o "release-assets/$TARBALL" | |
| git archive --format=zip --prefix="OmniRoute-${VERSION}/" HEAD -o "release-assets/$ZIPBALL" | |
| echo "✓ Created source archives:" | |
| ls -lh "release-assets/$TARBALL" "release-assets/$ZIPBALL" | |
| - name: List release files | |
| run: ls -la release-assets/ | |
| - name: Create Release | |
| uses: softprops/action-gh-release@v3 | |
| with: | |
| tag_name: ${{ needs.validate.outputs.version }} | |
| draft: false | |
| prerelease: false | |
| # Only on the tag push. A re-attach dispatch runs against a release whose curated | |
| # notes already exist, and `true` APPENDS GitHub's auto-generated "What's Changed" | |
| # block to them (v3.8.50, run 33238093090: +1,416 chars on a 121 KB body). | |
| generate_release_notes: ${{ github.event_name != 'workflow_dispatch' }} | |
| fail_on_unmatched_files: false | |
| files: | | |
| release-assets/*.dmg | |
| release-assets/*.exe | |
| release-assets/*.AppImage | |
| release-assets/*.deb | |
| release-assets/*.blockmap | |
| release-assets/*.yml | |
| release-assets/*.source.tar.gz | |
| release-assets/*.source.zip | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| verify-desktop-assets: | |
| name: Verify desktop assets landed | |
| needs: [validate, release] | |
| # Deliberately a SEPARATE job, not a final step of `release`: failing inside | |
| # `release` would cascade into `publish-npm` (which gates on `needs: release`) and | |
| # block the npm channel over a desktop-only gap. Here the assets are attached, npm | |
| # still publishes, and an incomplete desktop channel shows up as a red job instead | |
| # of passing unnoticed — the v3.8.49 release had ZERO assets and every gate was | |
| # green, because nothing ever asserted the release HAS binaries. | |
| if: ${{ !cancelled() && needs.release.result == 'success' }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Assert every platform is present on the release | |
| env: | |
| # Regex-validated (^v[0-9]+\.[0-9]+\.[0-9]+$) in the `validate` job, and | |
| # passed via env rather than interpolated into the script body. | |
| VERSION: ${{ needs.validate.outputs.version }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| names=$(gh release view "$VERSION" --repo "$GITHUB_REPOSITORY" \ | |
| --json assets --jq '.assets[].name') | |
| echo "Assets on $VERSION:" | |
| echo "$names" | sed 's/^/ /' | |
| missing="" | |
| # `[ ... ] && missing=...` as the last command in a branch returns 1 and | |
| # would abort the whole script under Actions' default `set -e`. Use if/fi. | |
| for want in '\.exe$' '\.dmg$' '\.AppImage$' '\.deb$' '^latest.*\.yml$' '\.source\.tar\.gz$'; do | |
| if ! echo "$names" | grep -qE "$want"; then | |
| missing="$missing $want" | |
| fi | |
| done | |
| if [ -n "$missing" ]; then | |
| echo "::error::Desktop channel incomplete on $VERSION — no asset matching:$missing" | |
| exit 1 | |
| fi | |
| echo "✓ every platform present on $VERSION" | |
| publish-npm: | |
| name: Publish to npm | |
| needs: [validate, release] | |
| # A re-dispatch that only re-attaches desktop assets must not publish the npm package again. | |
| if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_npm }} | |
| permissions: | |
| # Must be `write`, not `read`: this job calls the reusable npm-publish.yml whose | |
| # `publish` job needs `contents: write` (gh release upload — attach the SBOM, #3874). | |
| # A reusable workflow's job cannot request more permission than the caller grants, | |
| # so a `read` here makes GitHub reject the run at startup (startup_failure). | |
| # | |
| # `actions: read` for the same reason: the called `publish` job downloads the next-build | |
| # artefact and requests it. v3.8.50 (run 33005490476) died at startup with "The nested | |
| # job 'publish' is requesting 'actions: read', but is only allowed 'actions: none'" — and | |
| # because `release` lives in this same workflow, the tag shipped with ZERO assets. Keep | |
| # this block a superset of every job's permissions in npm-publish.yml. | |
| actions: read | |
| contents: write | |
| id-token: write # npm provenance (forwarded to the reusable workflow) | |
| packages: write # publish to npm.pkg.github.com | |
| uses: ./.github/workflows/npm-publish.yml | |
| with: | |
| version: ${{ needs.validate.outputs.version }} | |
| tag: latest | |
| secrets: | |
| NPM_TOKEN: ${{ secrets.NPM_TOKEN }} |