name: Release on: push: tags: - 'v*' workflow_call: inputs: tag: description: Tag to release, for example v1.2.3-rc.0 required: true type: string workflow_dispatch: inputs: tag: description: Existing tag to release, for example v1.2.3-rc.0 required: true type: string jobs: resolve-release: runs-on: ubuntu-latest outputs: tag: ${{ steps.resolve.outputs.tag }} ref: ${{ steps.resolve.outputs.ref }} steps: - name: Resolve release tag and ref id: resolve run: | if [[ "${{ github.event_name }}" == "push" ]]; then release_tag="${{ github.ref_name }}" release_ref="${{ github.ref }}" else release_tag="${{ inputs.tag }}" release_ref="refs/tags/${{ inputs.tag }}" fi echo "tag=$release_tag" >>"$GITHUB_OUTPUT" echo "ref=$release_ref" >>"$GITHUB_OUTPUT" create-release: needs: resolve-release runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout uses: actions/checkout@v4 with: ref: ${{ needs.resolve-release.outputs.ref }} - name: Create draft release with auto-generated notes run: | if gh release view "${{ needs.resolve-release.outputs.tag }}" --repo "${{ github.repository }}" >/dev/null 2>&1; then echo "Release ${{ needs.resolve-release.outputs.tag }} already exists." exit 0 fi gh release create "${{ needs.resolve-release.outputs.tag }}" \ --draft \ --generate-notes \ --prerelease=${{ contains(needs.resolve-release.outputs.tag, 'rc') }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Why: E2E runs alongside the release for visibility (failures surface as a # red check on the tag), but it is NOT in `release`'s needs list. Releases # already take a while and the suite is already a required check on PRs, so # gating here would mostly just delay shipping without adding much signal. # Matches the pattern used by noqa's app deploy, which runs E2E with # continue-on-error so failures are visible but don't block the deploy. e2e: needs: resolve-release uses: ./.github/workflows/e2e.yml with: ref: ${{ needs.resolve-release.outputs.ref }} release: needs: - resolve-release - create-release strategy: fail-fast: false matrix: include: - os: macos-15 platform: mac release_command: ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always eb_cache_path: | ~/Library/Caches/electron ~/Library/Caches/electron-builder - os: windows-latest platform: win release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always eb_cache_path: | ~\AppData\Local\electron\Cache ~\AppData\Local\electron-builder\Cache - os: ubuntu-latest platform: linux release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux --publish always eb_cache_path: | ~/.cache/electron ~/.cache/electron-builder runs-on: ${{ matrix.os }} permissions: contents: write steps: - name: Checkout uses: actions/checkout@v4 with: ref: ${{ needs.resolve-release.outputs.ref }} # pnpm must be on PATH before setup-node so setup-node can locate the store for caching. - name: Setup pnpm uses: pnpm/action-setup@v4 with: run_install: false - name: Setup Node.js uses: actions/setup-node@v4 with: node-version-file: package.json cache: pnpm # Why: release builds hit the same native-module postinstall path as # PR CI, so keep the pinned node-gyp override here too instead of # relying on pnpm's bundled copy. Scoped to Linux via runner.os (not # a specific matrix image) because the failing postinstall has only # been observed on Linux runners — see run 25081763129. The macOS # and Windows release jobs exercise the same pnpm install path and # have not reproduced it, so keep the gate narrow until we know why. # Using runner.os instead of matrix.os == 'ubuntu-latest' means the # gate still works if another Linux matrix entry is added later. - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) if: runner.os == 'Linux' run: | npm install -g node-gyp@11.5.0 echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" # Cache the Electron binary + electron-builder tool downloads (notarytool, # winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac. - name: Cache electron-builder downloads uses: actions/cache@v4 with: path: ${{ matrix.eb_cache_path }} key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | electron-builder-${{ matrix.platform }}- # Why: pnpm install triggers electron's postinstall, which downloads the # Electron binary from GitHub release assets. GitHub's download CDN # occasionally returns 504s that fail the whole release. Retry on # failure so transient network errors don't require a manual re-run. - name: Install dependencies uses: nick-fields/retry@v3 with: timeout_minutes: 10 max_attempts: 3 retry_wait_seconds: 30 command: pnpm install --frozen-lockfile - name: Verify macOS signing environment if: matrix.platform == 'mac' run: node config/scripts/verify-macos-release-env.mjs env: CSC_LINK: ${{ secrets.MAC_CERTS }} CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - name: Build app run: pnpm build:release # Why: macOS signing secrets (CSC_LINK, CSC_KEY_PASSWORD) must NOT be # passed to non-macOS builds. electron-builder uses CSC_LINK as the # code-signing certificate on any platform, so leaking the Apple # Developer ID cert to the Windows build causes the NSIS installer to # be signed with an Apple cert whose chain Windows cannot validate, # breaking the auto-updater with "certificate chain could not be built # to a trusted root authority" (issue #631). # # Why retry: electron-builder downloads NSIS/winCodeSign/squirrel # binaries and the Electron runtime from GitHub release assets during # publish. GitHub's download CDN occasionally returns 504s that fail # the whole release. Retry on failure so transient network errors # don't require a manual re-run. - name: Publish release artifacts (macOS) if: matrix.platform == 'mac' uses: nick-fields/retry@v3 with: timeout_minutes: 45 max_attempts: 3 retry_wait_seconds: 30 command: ${{ matrix.release_command }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.MAC_CERTS }} CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - name: Publish release artifacts if: matrix.platform != 'mac' uses: nick-fields/retry@v3 with: timeout_minutes: 30 max_attempts: 3 retry_wait_seconds: 30 command: ${{ matrix.release_command }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} publish-release: needs: - resolve-release - release runs-on: ubuntu-latest permissions: contents: write steps: - name: Publish release # Why: derive `--prerelease` from the tag shape (not from whatever # electron-builder left the release flagged as). On 2026-04-27, # electron-builder's publish step flipped `prerelease` back to # `false` on -rc.N releases, which caused an RC to be marked as # GitHub's "latest" release and broke release-cut.yml's math. # Re-asserting here means the final release state is determined # by the tag — a ground truth electron-builder can't rewrite. env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.resolve-release.outputs.tag }} run: | set -euo pipefail if [[ "$TAG" == *"-rc."* ]]; then prerelease=true else prerelease=false fi gh release edit "$TAG" \ --draft=false \ --prerelease="$prerelease" \ --repo "$GITHUB_REPOSITORY" homebrew-bump: needs: - resolve-release - publish-release if: ${{ !contains(needs.resolve-release.outputs.tag, '-rc.') }} uses: ./.github/workflows/homebrew-bump.yml with: tag: ${{ needs.resolve-release.outputs.tag }} secrets: inherit