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 # 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" build: name: Build Electron (${{ matrix.platform }}) needs: validate 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 - platform: macos-intel runner: macos-15-intel target: mac-x64 ext: .dmg - platform: macos-arm64 runner: macos-latest target: mac-arm64 ext: -arm64.dmg - platform: linux runner: ubuntu-latest target: linux ext: .AppImage deb_ext: .deb steps: - uses: actions/checkout@v7 with: persist-credentials: false - name: Setup Node uses: actions/setup-node@v6 with: node-version: 24 cache: npm - name: Cache node_modules uses: actions/cache@v6.1.0 with: path: node_modules key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - 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 env: JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation NODE_OPTIONS: "--max_old_space_size=6144" run: npm run build - 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 install --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' env: ELECTRON_SMOKE_TIMEOUT_MS: 60000 ELECTRON_SMOKE_STREAM_LOGS: "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 - name: Upload artifacts uses: actions/upload-artifact@v7 with: name: electron-${{ matrix.platform }} path: release-assets/ release: name: Create Release needs: [validate, build] 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 - name: Download all artifacts uses: actions/download-artifact@v8 with: path: release-assets merge-multiple: true - 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 generate_release_notes: true fail_on_unmatched_files: false files: | release-assets/*.dmg release-assets/*.exe release-assets/*.AppImage release-assets/*.deb release-assets/*.blockmap release-assets/*.source.tar.gz release-assets/*.source.zip env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} publish-npm: name: Publish to npm needs: [validate, release] 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). 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 }}