Skip to content

Release Electron App #278

Release Electron App

Release Electron App #278

Workflow file for this run

name: Release Electron App
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Tag to release (e.g. v1.0.57)"
required: true
type: string
permissions:
contents: write
# Prevent two runs for the same tag from racing on the same release assets.
concurrency:
group: release-${{ inputs.tag || github.ref_name }}
cancel-in-progress: false
jobs:
# Keep notes first: build jobs only start after the release exists with its
# real notes, preserving the existing updater/release safety semantics.
notes:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Fetch dev for stable release notes
run: git fetch origin dev:refs/remotes/origin/dev || true
- name: Generate release notes and create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag || github.ref_name }}
RELEASE_NOTES_BASE_URL: ${{ secrets.RELEASE_NOTES_BASE_URL }}
RELEASE_NOTES_API_KEY: ${{ secrets.RELEASE_NOTES_API_KEY }}
RELEASE_NOTES_MODEL: ${{ secrets.RELEASE_NOTES_MODEL }}
run: |
bash .github/scripts/generate-release-notes.sh "$TAG" > /tmp/release-notes.md
# Tag like v1.2.3-beta.SHA -> prerelease. Stable releases remain
# non-latest until every platform has passed smoke and uploaded.
PRERELEASE_FLAG=""
LATEST_FLAG=""
case "$TAG" in
*-*) PRERELEASE_FLAG="--prerelease" ;;
*) LATEST_FLAG="--latest=false" ;;
esac
PUBLISHED=""
for i in 1 2 3; do
if gh release edit "$TAG" --draft=false $PRERELEASE_FLAG $LATEST_FLAG --notes-file /tmp/release-notes.md; then
PUBLISHED=1
break
fi
if gh release create "$TAG" --title "$TAG" $PRERELEASE_FLAG $LATEST_FLAG --notes-file /tmp/release-notes.md; then
PUBLISHED=1
break
fi
echo "release edit/create failed (attempt $i/3), retrying..."
sleep $(( i * 15 ))
done
if [ -z "$PUBLISHED" ]; then
echo "::error::could not create or update release $TAG after 3 attempts"
exit 1
fi
# All four targets now build in parallel. macOS x64 uses a native Intel
# runner instead of waiting for the ARM64 job and running through Rosetta.
build:
needs: notes
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
platform: win
- os: macos-latest
platform: mac
arch: arm64
- os: macos-15-intel
platform: mac
arch: x64
- os: ubuntu-latest
platform: linux
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: |
package-lock.json
web/package-lock.json
native/package-lock.json
- name: Cache Electron / electron-builder downloads
uses: actions/cache@v4
with:
path: |
~/.cache/electron
~/.cache/electron-builder
~/Library/Caches/electron
~/Library/Caches/electron-builder
~/AppData/Local/electron/Cache
~/AppData/Local/electron-builder/Cache
key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package-lock.json', 'packages/electron/electron-builder.yml') }}
restore-keys: |
electron-${{ runner.os }}-${{ runner.arch }}-
- name: Install all workspace dependencies
run: npm ci --ignore-scripts --prefer-offline --no-audit --no-fund
- name: Install web frontend dependencies
working-directory: web
run: npm ci --prefer-offline --no-audit --no-fund
- name: Build core (web + tsc)
run: npm run build
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo + Rust build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
native/target
key: native-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/Cargo.lock') }}
restore-keys: |
native-${{ runner.os }}-${{ runner.arch }}-
- name: Install native addon dependencies
working-directory: native
run: npm ci --prefer-offline --no-audit --no-fund
- name: Build native addon
working-directory: native
run: npm run build
- name: Bundle Electron (esbuild)
working-directory: packages/electron
run: node electron/build.mjs
- name: Prepare pack (copy root resources)
working-directory: packages/electron
run: node electron/prepare-pack.mjs
- name: Pack (${{ matrix.platform }}${{ matrix.arch && format('-{0}', matrix.arch) || '' }})
working-directory: packages/electron
shell: bash
run: |
TAG="${{ inputs.tag || github.ref_name }}"
VERSION="${TAG#v}"
npx electron-builder --config electron-builder.yml \
--${{ matrix.platform }} ${{ matrix.arch && format('--{0}', matrix.arch) || '' }} \
--config.extraMetadata.version="$VERSION" \
--publish never
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install AppImage runtime (Linux only)
if: matrix.platform == 'linux'
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends libfuse2 xvfb
- name: Smoke test packaged binary (win)
if: matrix.platform == 'win'
shell: pwsh
env:
RELEASE_DIR: packages/electron/release
SMOKE_LOG: ${{ runner.temp }}/electron-smoke.log
run: ./.github/scripts/electron-smoke.ps1
- name: Smoke test packaged binary (${{ matrix.platform }}${{ matrix.arch && format('-{0}', matrix.arch) || '' }})
if: matrix.platform != 'win'
shell: bash
env:
RELEASE_DIR: packages/electron/release
MAC_ARCH: ${{ matrix.arch }}
SMOKE_LOG: ${{ runner.temp }}/electron-smoke.log
run: bash .github/scripts/electron-smoke.sh
- name: Upload smoke log on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: electron-smoke-${{ matrix.platform }}${{ matrix.arch && format('-{0}', matrix.arch) || '' }}-log
path: ${{ runner.temp }}/electron-smoke.log
if-no-files-found: ignore
retention-days: 7
# Each mac build generates its own latest-mac.yml. Do not upload either
# one directly to the release (same filename); save them separately and
# merge once both native-architecture builds complete.
- name: Save mac update manifest
if: matrix.platform == 'mac'
uses: actions/upload-artifact@v4
with:
name: mac-update-manifest-${{ matrix.arch }}
path: packages/electron/release/latest-mac.yml
if-no-files-found: error
retention-days: 1
- name: Upload artifacts to release (${{ matrix.platform }}${{ matrix.arch && format('-{0}', matrix.arch) || '' }})
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag || github.ref_name }}
run: |
cd packages/electron/release
files=()
case "${{ matrix.platform }}" in
mac)
# Preserve the old asset-selection behavior while allowing both
# architectures to upload concurrently. The shared manifest is
# intentionally excluded and merged in the next job.
if [ "${{ matrix.arch }}" = "arm64" ]; then
for f in *arm64*; do
[ -f "$f" ] && files+=("$f")
done
else
for f in *; do
[ -f "$f" ] || continue
echo "$f" | grep -qi "arm64" && continue
[[ "$f" == *.yml || "$f" == *.yaml ]] && continue
files+=("$f")
done
fi
;;
linux)
for f in Codex-Proxy-*-linux* latest-linux.yml; do
[ -f "$f" ] && files+=("$f")
done
;;
win)
for f in Codex-Proxy-*-win* latest.yml; do
[ -f "$f" ] && files+=("$f")
done
;;
esac
if [ ${#files[@]} -eq 0 ]; then
echo "::error::no release artifacts found for ${{ matrix.platform }}${{ matrix.arch && format('-{0}', matrix.arch) || '' }}"
exit 1
fi
printf 'Uploading: %s\n' "${files[@]}"
# One gh invocation per platform instead of one HTTP/API cycle per file.
gh release upload "$TAG" "${files[@]}" --clobber
# Merge the two architecture-specific mac manifests after both mac builds
# have completed. This job is tiny and removes the old full x64 serial build.
merge-mac-manifest:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download mac manifests
uses: actions/download-artifact@v4
with:
pattern: mac-update-manifest-*
path: /tmp/mac-manifests
- name: Merge latest-mac.yml
shell: bash
run: |
ARM64_YML="/tmp/mac-manifests/mac-update-manifest-arm64/latest-mac.yml"
X64_YML="/tmp/mac-manifests/mac-update-manifest-x64/latest-mac.yml"
[ -f "$ARM64_YML" ] || { echo "::error::missing arm64 latest-mac.yml"; exit 1; }
[ -f "$X64_YML" ] || { echo "::error::missing x64 latest-mac.yml"; exit 1; }
ruby <<'RUBY'
require 'yaml'
arm64_path = '/tmp/mac-manifests/mac-update-manifest-arm64/latest-mac.yml'
x64_path = '/tmp/mac-manifests/mac-update-manifest-x64/latest-mac.yml'
arm64 = YAML.unsafe_load_file(arm64_path)
x64 = YAML.unsafe_load_file(x64_path)
arm64_files = Array(arm64['files']).select { |f| f['url'].to_s.include?('arm64') }
x64_files = Array(x64['files']).reject { |f| f['url'].to_s.include?('arm64') }
if arm64_files.empty?
abort 'arm64 manifest contains no arm64 file entries'
end
if x64_files.empty?
abort 'x64 manifest contains no x64 file entries'
end
merged = arm64
merged['files'] = arm64_files + x64_files
output = YAML.dump(merged).sub(/\A---\s*\n/, '')
File.write('/tmp/latest-mac.yml', output)
RUBY
- name: Upload merged mac manifest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag || github.ref_name }}
# This job has no checkout, so gh cannot auto-detect the repo. Pass
# --repo explicitly (GITHUB_REPOSITORY is set on every runner) — gh
# release upload does NOT accept a repo positional.
run: gh release upload --repo "$GITHUB_REPOSITORY" "$TAG" /tmp/latest-mac.yml --clobber
notify:
needs: [notes, build, merge-mac-manifest]
runs-on: ubuntu-latest
if: always() && (startsWith(github.ref, 'refs/tags/v') || inputs.tag)
steps:
- name: Checkout (scripts only)
uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
- name: Mark stable release as latest (assets are complete now)
id: flip
if: ${{ !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag || github.ref_name }}
run: |
case "$TAG" in *-*) echo "prerelease, never latest"; exit 0 ;; esac
for i in 1 2 3; do
if gh release edit "$TAG" --latest; then exit 0; fi
echo "marking latest failed (attempt $i/3), retrying..."
sleep 10
done
echo "::error::failed to mark $TAG as latest after 3 attempts"
exit 1
- name: Delete asset-less release on build failure
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag || github.ref_name }}
run: |
COUNT=$(gh release view "$TAG" --json assets -q '.assets | length' 2>/dev/null || echo "")
if [ "$COUNT" = "0" ]; then
echo "deleting zero-asset release $TAG (tag kept)"
gh release delete "$TAG" -y || true
else
echo "release $TAG has ${COUNT:-unknown} asset(s), leaving it in place"
fi
- name: Send webhook notification
if: always()
env:
NOTIFY_WEBHOOK_URL: ${{ secrets.NOTIFY_WEBHOOK_URL }}
TAG: ${{ inputs.tag || github.ref_name }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
RELEASE_URL: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ inputs.tag || github.ref_name }}
run: |
if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || steps.flip.outcome == 'failure' }}" = "true" ]; then
MSG="❌ Release $TAG failed: $RUN_URL"
else
MSG="✅ Release $TAG published: $RELEASE_URL"
fi
bash .github/scripts/notify-webhook.sh "$MSG"