ci(release): parallelize mac builds and add electron/cargo caches #268
Workflow file for this run
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: 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 }} | |
| run: gh release upload "$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" |