diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6b2473887..9d2eddc75 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -80,7 +80,7 @@ All releases are cut from the **Cut Release** GitHub Actions workflow. There is - **ref**: the branch, tag, or SHA to build from. Defaults to `main`. 3. Run it. -The workflow resolves the next version from GitHub Releases, bumps `package.json`, tags, pushes, and kicks off the multi-platform build via `release.yml`. +The workflow resolves the next version from GitHub Releases, bumps `package.json`, tags, pushes, and runs the multi-platform build + publish inline. **How the next version is chosen:** diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml index 9f1f37341..28dac4081 100644 --- a/.github/workflows/homebrew-bump.yml +++ b/.github/workflows/homebrew-bump.yml @@ -2,15 +2,21 @@ name: Homebrew Cask Bump # Why: keep the stablyai/homebrew-orca tap in sync with GitHub Releases. # Triggers: -# - release.published with !prerelease → release workflow just finalized a -# stable tag (see publish-release step in release.yml) -# - workflow_call → release.yml finished publishing a stable tag +# - workflow_call → release-cut.yml finished publishing a stable tag # - workflow_dispatch → manual backfill for a specific tag # # The job computes the sha256 of orca-macos-arm64.dmg + orca-macos-x64.dmg # from the release, templates Casks/orca.rb in this repo with the new # version and hashes, then PRs the result into stablyai/homebrew-orca. It # does NOT run for -rc.* tags: the stable cask should only track GA. +# +# Why no `release.published` trigger: other products shipped from this repo +# (e.g. mobile under `mobile-v*`) also publish GitHub Releases. A blanket +# `release.published` trigger would fire this workflow for any of them and +# attempt to download `orca-macos-*.dmg` from a non-desktop release, at +# best 404ing and at worst (if the filter were weaker) rewriting the cask +# to a non-desktop version. The tap is only ever bumped by the desktop +# release pipeline calling this workflow directly. on: workflow_call: @@ -19,8 +25,6 @@ on: description: Release tag to sync (e.g. v1.3.25) required: true type: string - release: - types: [published] workflow_dispatch: inputs: tag: @@ -31,12 +35,15 @@ on: jobs: bump-cask: runs-on: ubuntu-latest - # Why: skip prereleases and RC tags. The stable cask tracks GA only; a - # beta channel would be a separate cask in a separate job. + # Why: defense in depth. The desktop release pipeline already refuses + # to invoke this workflow for `-rc.*` tags, but a manual backfill could + # still pass one in — reject it here so the stable cask can never point + # at an RC. Also requires the desktop tag shape `v...` so a + # mis-typed mobile tag like `mobile-v0.0.1` is rejected up front. if: > - inputs.tag != '' || - (github.event.release.prerelease == false && - !contains(github.event.release.tag_name, '-rc.')) + inputs.tag != '' && + !contains(inputs.tag, '-rc.') && + startsWith(inputs.tag, 'v') permissions: contents: read steps: diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index a477e80e8..2b9d0bde0 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -183,20 +183,28 @@ jobs: run: | set -euo pipefail - # Latest stable release tag, picked by *tag shape* (no `-rc.`) - # rather than the GitHub `isPrerelease` flag. electron-builder's - # publish step has flipped that flag back to `false` on RC - # releases in the past (issue surfaced 2026-04-27 when - # v1.3.22-rc.2 became "latest" on GitHub and poisoned the - # compute-next-version math here). Filtering by tag format is - # the authoritative signal — an `-rc.N` tag is never stable, - # regardless of what the release metadata says. + # Latest stable release tag, picked by *tag shape*: + # - must start with `v` (desktop convention, e.g. v1.3.32) + # - must NOT contain `-rc.` (not a prerelease) + # + # Why not the GitHub `isPrerelease` flag: electron-builder's publish + # step has flipped that flag back to `false` on RC releases before + # (v1.3.22-rc.2 on 2026-04-27 briefly became "latest" on GitHub and + # poisoned the math here). Tag format is authoritative. + # + # Why the `^v[0-9]` prefix: other products shipped from this repo + # use their own prefixes (e.g. `mobile-v0.0.1`). Without the prefix + # gate the latest mobile release would be selected as "latest + # stable", then `strip_pre()` would reduce `mobile-v0.0.1` to + # `mobile`, `Number("mobile")` → NaN → 0, and a patch-bump would + # produce `0.0.1` — exactly the wedge on 2026-05-04 (run + # 25304336767). Any non-desktop tag shape must be excluded here. latest_stable="$(gh release list \ --repo "$GITHUB_REPOSITORY" \ --exclude-drafts \ --limit 50 \ --json tagName \ - --jq '[.[] | .tagName | select(test("-rc\\.") | not)] | .[0] // ""')" + --jq '[.[] | .tagName | select(test("^v[0-9]") and (test("-rc\\.") | not))] | .[0] // ""')" latest_stable="${latest_stable#v}" echo "Latest stable: ${latest_stable:-}" @@ -369,10 +377,227 @@ jobs: git push origin "$TAG" fi - release: + create-release: needs: cut if: needs.cut.outputs.should_release == 'true' - uses: ./.github/workflows/release.yml + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: refs/tags/${{ needs.cut.outputs.tag }} + + - name: Create draft release with auto-generated notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.cut.outputs.tag }} + run: | + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $TAG already exists." + exit 0 + fi + + is_rc=false + if [[ "$TAG" == *"-rc."* ]]; then + is_rc=true + fi + + gh release create "$TAG" \ + --draft \ + --generate-notes \ + --prerelease="$is_rc" + + # Why: E2E runs alongside the release for visibility (failures surface as a + # red check on the tag), but is NOT in `publish-release`'s needs list. + # Releases already take a while and the suite is already a required check + # on PRs, so gating here would mostly delay shipping without adding signal. + e2e: + needs: cut + if: needs.cut.outputs.should_release == 'true' + uses: ./.github/workflows/e2e.yml + with: + ref: refs/tags/${{ needs.cut.outputs.tag }} + + build: + needs: + - cut + - create-release + if: needs.cut.outputs.should_release == 'true' + 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: refs/tags/${{ needs.cut.outputs.tag }} + + # 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: + - cut + - build + 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.cut.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: + - cut + - publish-release + if: ${{ !contains(needs.cut.outputs.tag, '-rc.') }} + uses: ./.github/workflows/homebrew-bump.yml with: tag: ${{ needs.cut.outputs.tag }} secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 1706afbc9..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,257 +0,0 @@ -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