Repository navigation
Cut Release #257
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: Cut Release | |
| # Why: single entry point for manually and scheduled cutting releases. | |
| # Replaces the old local `pnpm release:*` scripts and the standalone scheduled | |
| # RC workflow so releases are always reproducible from CI and can never be | |
| # accidentally tagged against an uncommitted or non-main working tree. | |
| # | |
| # Flow: | |
| # 1. Resolve `ref` to a SHA. | |
| # 2. Read the latest stable release from GitHub. | |
| # 3. Compute the next version from `kind` (rc | patch | minor | major). | |
| # 4. For stable kinds, REFUSE if the new version is <= the latest stable. | |
| # This is the only guard electron-updater actually needs — it compares | |
| # semver within a channel, so a regressing "latest" is the one thing | |
| # that breaks auto-update for fresh installs. | |
| # 5. Write package.json, commit (detached), tag, push tag. | |
| # 6. If ref was the tip of origin/main, fast-forward main to include the | |
| # version-bump commit so developers see the right version locally. | |
| # 7. Build and publish artifacts from the tag. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| kind: | |
| description: Release kind | |
| required: true | |
| type: choice | |
| default: rc | |
| options: | |
| - rc | |
| - patch | |
| - minor | |
| - major | |
| ref: | |
| description: Branch, tag, or SHA to release from (default main) | |
| required: false | |
| type: string | |
| default: main | |
| dry_run: | |
| description: Validate a scheduled-RC run without creating a tag | |
| required: false | |
| default: false | |
| type: boolean | |
| schedule: | |
| # Why: GitHub scheduled workflows are not guaranteed to start exactly on | |
| # time and can be delayed or dropped around high-load periods. We retry | |
| # across the full target hour and dedupe per PT slot. Cron is UTC, so run | |
| # during both PST and PDT equivalents and let the PT-hour gate decide. | |
| - cron: '*/5 10,11,22,23 * * *' | |
| permissions: | |
| contents: write | |
| concurrency: | |
| group: release-cut | |
| cancel-in-progress: false | |
| jobs: | |
| cut: | |
| # Why: this job bumps package.json and fast-forwards main. On a fork with | |
| # Actions enabled, the scheduled cut would run against the fork's main and | |
| # diverge it (version line) every slot, conflicting every PR back upstream. | |
| # Gate to the canonical repo so the workflow no-ops on forks. | |
| if: github.repository == 'stablyai/orca' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| outputs: | |
| tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }} | |
| should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }} | |
| latest_published_rc_tag: ${{ steps.publish_drafts.outputs.latest_published_tag }} | |
| steps: | |
| - name: Checkout ref | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }} | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version-file: package.json | |
| - name: Configure git author | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| - name: Resolve ref SHA | |
| id: resolve | |
| run: | | |
| sha="$(git rev-parse HEAD)" | |
| echo "sha=$sha" >>"$GITHUB_OUTPUT" | |
| # Why: only push the version-bump commit back to main when the | |
| # caller is releasing the exact tip of main. For any older or | |
| # off-main ref we leave main alone and only publish the tag. | |
| git fetch origin main --quiet | |
| main_sha="$(git rev-parse origin/main)" | |
| if [[ "$sha" == "$main_sha" ]]; then | |
| echo "push_main=true" >>"$GITHUB_OUTPUT" | |
| else | |
| echo "push_main=false" >>"$GITHUB_OUTPUT" | |
| fi | |
| - name: Compute RC slot | |
| id: slot | |
| run: | | |
| slot=$(TZ=America/Los_Angeles date '+%Y-%m-%d-%H') | |
| echo "value=$slot" >>"$GITHUB_OUTPUT" | |
| - name: Validate PT release window | |
| id: window | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| run: | | |
| if [[ "$EVENT_NAME" != "schedule" ]]; then | |
| echo "allowed=true" >>"$GITHUB_OUTPUT" | |
| echo "reason=manual" >>"$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| pt_hour=$(TZ=America/Los_Angeles date '+%H') | |
| pt_minute=$(TZ=America/Los_Angeles date '+%M') | |
| # Why: GitHub may deliver a scheduled event long after the intended | |
| # time, so delayed 4:16 AM runs must not cut the 3:00 AM release. | |
| if [[ "$pt_hour" == "03" || "$pt_hour" == "15" ]]; then | |
| echo "allowed=true" >>"$GITHUB_OUTPUT" | |
| echo "reason=target_hour:${pt_hour}:${pt_minute}" >>"$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "allowed=false" >>"$GITHUB_OUTPUT" | |
| echo "reason=outside_target_hour:${pt_hour}:${pt_minute}" >>"$GITHUB_OUTPUT" | |
| - name: Skip if this PT release window already ran | |
| id: existing | |
| if: github.event_name == 'schedule' && steps.window.outputs.allowed == 'true' | |
| run: | | |
| # Why: scheduled runs retry inside each target hour, so make the | |
| # schedule idempotent by embedding a slot marker in the release commit. | |
| if git log origin/main --grep="\\[rc-slot:${{ steps.slot.outputs.value }}\\]" -n 1 --format=%H | grep -q .; then | |
| echo "already_ran=true" >>"$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Why: this preserves dedupe across the older scheduled workflow's | |
| # first runs, before all RC cuts shared release-cut's slot marker. | |
| latest_rc_tag="$(git for-each-ref --sort=-creatordate --format='%(refname:short) %(creatordate:iso-strict)' 'refs/tags/v*-rc.*' | head -n 1)" | |
| if [[ -n "$latest_rc_tag" ]]; then | |
| latest_rc_tag_name="${latest_rc_tag%% *}" | |
| latest_rc_tag_date="${latest_rc_tag#* }" | |
| latest_rc_slot="$(TZ=America/Los_Angeles date -d "$latest_rc_tag_date" '+%Y-%m-%d-%H')" | |
| if [[ "$latest_rc_slot" == "${{ steps.slot.outputs.value }}" ]]; then | |
| echo "already_ran=true" >>"$GITHUB_OUTPUT" | |
| echo "reason=latest_rc_tag:$latest_rc_tag_name" >>"$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| fi | |
| echo "already_ran=false" >>"$GITHUB_OUTPUT" | |
| - name: Dry run summary | |
| if: github.event_name == 'workflow_dispatch' && inputs.dry_run | |
| run: | | |
| echo "Dry run only." | |
| echo "Current PT slot: ${{ steps.slot.outputs.value }}" | |
| echo "Window allowed: ${{ steps.window.outputs.allowed }}" | |
| echo "Window reason: ${{ steps.window.outputs.reason }}" | |
| echo "Already ran this slot: ${{ steps.existing.outputs.already_ran }}" | |
| echo "Reason: ${{ steps.existing.outputs.reason }}" | |
| - name: Skip summary | |
| if: steps.window.outputs.allowed != 'true' || steps.existing.outputs.already_ran == 'true' | |
| run: | | |
| echo "Skipping release cut." | |
| echo "Current PT slot: ${{ steps.slot.outputs.value }}" | |
| echo "Window reason: ${{ steps.window.outputs.reason }}" | |
| echo "Already ran this slot: ${{ steps.existing.outputs.already_ran }}" | |
| echo "Reason: ${{ steps.existing.outputs.reason }}" | |
| - name: Publish complete release-cut RC drafts from prior runs | |
| id: publish_drafts | |
| # Why: this must run even when the current slot already cut a tag; a | |
| # later cron retry may be the first chance to unstick a complete RC draft. | |
| if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node config/scripts/publish-complete-draft-releases.mjs | |
| - name: Compute next version | |
| id: version | |
| # Why: if an RC run only had complete drafts to publish, stop there | |
| # instead of immediately cutting another RC after the recovered one. | |
| # Stable dispatches should still cut the requested stable release. | |
| if: steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) && !((github.event_name == 'schedule' || inputs.kind == 'rc') && steps.publish_drafts.outputs.published_count != '0' && steps.publish_drafts.outputs.skipped_count == '0') | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }} | |
| run: | | |
| set -euo pipefail | |
| # Latest stable release tag, picked by *tag shape* and semver max: | |
| # - must start with `v<digit>` (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. | |
| # | |
| # Why not `gh release list` order: GitHub can list a newer published | |
| # stable after older releases. On 2026-06-04, v1.4.44 existed but | |
| # the list returned v1.4.42 first, causing a manual RC cut to reopen | |
| # the already-shipped 1.4.43 series as v1.4.43-rc.0. | |
| latest_stable="$(node config/scripts/latest-stable-release.mjs)" | |
| latest_stable="${latest_stable#v}" | |
| echo "Latest stable: ${latest_stable:-<none>}" | |
| # Strip any prerelease suffix before numeric math. Without this, | |
| # `Number("1-rc")` returns NaN and `(NaN||0)+1` silently collapses | |
| # to 1 — exactly the path that produced v1.3.1-rc.4 on 2026-04-27 | |
| # when latest_stable was misread as a prerelease tag. | |
| strip_pre() { echo "${1%%-*}"; } | |
| semver_gt() { | |
| # returns 0 if $1 > $2 by semver rules (ignoring prerelease) | |
| node -e ' | |
| const a = process.argv[1].split(".").map(Number); | |
| const b = process.argv[2].split(".").map(Number); | |
| for (let i = 0; i < 3; i++) { | |
| if ((a[i]||0) > (b[i]||0)) process.exit(0); | |
| if ((a[i]||0) < (b[i]||0)) process.exit(1); | |
| } | |
| process.exit(1); | |
| ' "$(strip_pre "$1")" "$(strip_pre "$2")" | |
| } | |
| bump() { | |
| # $1=version, $2=level (patch|minor|major) | |
| node -e ' | |
| const v = process.argv[1].split(".").map(Number); | |
| const level = process.argv[2]; | |
| if (level === "major") console.log(`${(v[0]||0)+1}.0.0`); | |
| else if (level === "minor") console.log(`${v[0]||0}.${(v[1]||0)+1}.0`); | |
| else console.log(`${v[0]||0}.${v[1]||0}.${(v[2]||0)+1}`); | |
| ' "$(strip_pre "$1")" "$2" | |
| } | |
| highest_rc_for_base() { | |
| node config/scripts/release-rc-history.mjs "$1" | |
| } | |
| current_package_stable() { | |
| node -e ' | |
| const { version } = require("./package.json"); | |
| if (/^[0-9]+\.[0-9]+\.[0-9]+$/.test(version)) console.log(version); | |
| ' | |
| } | |
| tag_matches_current_ref() { | |
| local tag="$1" | |
| local tag_commit | |
| local head_commit | |
| if ! tag_commit="$(git rev-parse "${tag}^{}" 2>/dev/null)"; then | |
| return 1 | |
| fi | |
| head_commit="$(git rev-parse HEAD)" | |
| if [[ "$tag_commit" == "$head_commit" ]]; then | |
| return 0 | |
| fi | |
| local tag_parent | |
| tag_parent="$(git rev-parse "${tag_commit}^" 2>/dev/null)" || return 1 | |
| [[ "$tag_parent" == "$head_commit" ]] | |
| } | |
| release_draft_state() { | |
| # Prints: true, false, or missing. | |
| local tag="$1" | |
| local state_file="$RUNNER_TEMP/release-state-${tag//[^A-Za-z0-9_.-]/_}" | |
| if gh release view "$tag" \ | |
| --repo "$GITHUB_REPOSITORY" \ | |
| --json isDraft \ | |
| --jq '.isDraft' >"$state_file" 2>/dev/null; then | |
| cat "$state_file" | |
| else | |
| echo "missing" | |
| fi | |
| } | |
| recover_unpublished_tag() { | |
| local tag="$1" | |
| local reason="$2" | |
| local release_state | |
| release_state="$(release_draft_state "$tag")" | |
| case "$release_state" in | |
| missing|true) | |
| if ! tag_matches_current_ref "$tag"; then | |
| echo "::warning::Tag $tag already exists but was cut from a different release ref ($reason) - cutting the next version instead of reusing stale artifacts." | |
| return 1 | |
| fi | |
| echo "::warning::Tag $tag already exists but has no published release ($reason) - recovering by re-dispatching the release build against the existing tag." | |
| echo "recovered_tag=$tag" >>"$GITHUB_OUTPUT" | |
| echo "recovered=true" >>"$GITHUB_OUTPUT" | |
| exit 0 | |
| ;; | |
| false) | |
| return 1 | |
| ;; | |
| *) | |
| echo "::error::Unexpected release state for $tag: $release_state" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| } | |
| # Fresh repo fallback so the math below never divides by zero. | |
| if [[ -z "$latest_stable" ]]; then | |
| latest_stable="0.0.0" | |
| fi | |
| package_stable="$(current_package_stable)" | |
| if [[ -n "$package_stable" ]]; then | |
| # Why: if a stable release is deleted after its version-bump commit | |
| # reached main, GitHub's release list regresses. package.json is the | |
| # floor for the current ref so the next cut cannot reuse an older | |
| # stable number just because the public release was nuked. | |
| if semver_gt "$package_stable" "$latest_stable"; then | |
| if [[ "$KIND" != "rc" ]]; then | |
| package_tag="v$package_stable" | |
| if git rev-parse "$package_tag" >/dev/null 2>&1; then | |
| recover_unpublished_tag "$package_tag" "current ref stable tag is newer than latest published stable" || true | |
| fi | |
| fi | |
| echo "Stable floor from package.json: $package_stable" | |
| latest_stable="$package_stable" | |
| fi | |
| fi | |
| case "$KIND" in | |
| rc) | |
| # Why: RCs always stabilize the *next* patch after whatever | |
| # is currently published as stable. Earlier logic tried to | |
| # "continue the current series" by reading the highest git | |
| # tag, which silently reopened a series that had already | |
| # shipped (e.g. cutting v1.3.21-rc.7 after v1.3.21 stable | |
| # was out). Anchoring to latest_stable + patch eliminates | |
| # that class of bug; minor/major RCs are cut by running | |
| # that stable kind first. | |
| base="$(bump "$latest_stable" patch)" | |
| highest_rc="$(highest_rc_for_base "$base")" | |
| if [[ -z "$highest_rc" ]]; then | |
| new="${base}-rc.0" | |
| else | |
| existing_rc_tag="v${base}-rc.${highest_rc}" | |
| # Why: a failed or GitHub-stuck run can leave the highest RC | |
| # tag attached to a draft/missing release. Resume only when it | |
| # was cut from this ref; stale attempts advance to rc.N+1. | |
| if git rev-parse "$existing_rc_tag" >/dev/null 2>&1; then | |
| recover_unpublished_tag "$existing_rc_tag" "latest RC in series" || true | |
| fi | |
| new="${base}-rc.$((highest_rc + 1))" | |
| fi | |
| ;; | |
| patch|minor|major) | |
| new="$(bump "$latest_stable" "$KIND")" | |
| # Updater-safety gate: stable must strictly increase. | |
| if ! semver_gt "$new" "$latest_stable"; then | |
| echo "::error::Refusing to cut $KIND $new: not greater than latest stable $latest_stable." >&2 | |
| exit 1 | |
| fi | |
| # Why: a stale orphan stable tag can exist from an older release | |
| # ref after main has moved on. If it cannot be recovered for the | |
| # current ref, advance to the next stable version instead of | |
| # wedging every future patch cut on the same collision. | |
| for _ in {1..100}; do | |
| candidate_tag="v$new" | |
| if ! git rev-parse "$candidate_tag" >/dev/null 2>&1; then | |
| break | |
| fi | |
| candidate_release_state="$(release_draft_state "$candidate_tag")" | |
| case "$candidate_release_state" in | |
| missing|true) | |
| recover_unpublished_tag "$candidate_tag" "tag collision" || true | |
| new="$(bump "$new" "$KIND")" | |
| ;; | |
| false) | |
| echo "::error::Tag $candidate_tag already exists with a published release. Refusing to skip over a shipped version." >&2 | |
| exit 1 | |
| ;; | |
| *) | |
| echo "::error::Unexpected release state for $candidate_tag: $candidate_release_state" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| done | |
| ;; | |
| *) | |
| echo "::error::Unknown kind: $KIND" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| # Orphan-tag recovery. | |
| # | |
| # Why: if a previous cut pushed the tag but was cancelled (or the | |
| # dependent release build jobs otherwise failed to start) before the | |
| # GitHub Release was published, the tag now exists on the remote | |
| # but "latest stable" still points at the prior version. Every | |
| # subsequent patch cut then recomputes the same version and dies | |
| # on "Tag already exists." This exact sequence wedged the cut | |
| # pipeline on 2026-05-01 when v1.3.26 was pushed by a cancelled | |
| # run (25237882049) — every patch cut after that rehit the same | |
| # tag for hours until the orphan release was dispatched by hand. | |
| # | |
| # Recovery policy: if the tag exists AND no GitHub release has | |
| # been published for it (draft-or-absent both count as "not | |
| # shipped"), treat this as a resumable state: emit the existing | |
| # tag as the job output so the downstream release build jobs run | |
| # against it and finishes what the earlier attempt started. The | |
| # bump/commit/push steps are skipped in that case — there is | |
| # nothing to bump; the tag is already on the remote. | |
| # | |
| # Refuse collisions only when the tag *and* a published release | |
| # already exist — that's a real conflict (someone tagged manually | |
| # over a shipped version) and needs human attention. | |
| if git rev-parse "v$new" >/dev/null 2>&1; then | |
| recover_unpublished_tag "v$new" "tag collision" || { | |
| echo "::error::Tag v$new already exists and cannot be recovered for this ref. Refusing to re-cut over an existing version." >&2 | |
| exit 1 | |
| } | |
| fi | |
| echo "version=$new" >>"$GITHUB_OUTPUT" | |
| echo "Next version: $new" | |
| - name: Bump package.json and tag | |
| id: tag | |
| if: steps.version.outputs.version != '' && steps.version.outputs.recovered != 'true' | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| SLOT: ${{ steps.slot.outputs.value }} | |
| VERSION: ${{ steps.version.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| # Why: use npm version --no-git-tag-version so we control the commit | |
| # message and tag name explicitly (avoids npm's `v1.2.3` prefix | |
| # assumptions and any lifecycle scripts that would run on bump). | |
| npm version "$VERSION" --no-git-tag-version --allow-same-version | |
| git add package.json | |
| commit_message="release: v$VERSION" | |
| if [[ "$EVENT_NAME" == "schedule" ]]; then | |
| commit_message="$commit_message [rc-slot:$SLOT]" | |
| fi | |
| if git diff --cached --quiet; then | |
| # Why: a failed cut can push the version bump to main before the | |
| # release is published. Re-cutting then needs a fresh taggable | |
| # release commit even though package.json is already at VERSION. | |
| git commit --allow-empty -m "$commit_message" | |
| else | |
| git commit -m "$commit_message" | |
| fi | |
| git tag -a "v$VERSION" -m "v$VERSION" | |
| echo "tag=v$VERSION" >>"$GITHUB_OUTPUT" | |
| echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" | |
| - name: Push tag | |
| if: steps.tag.outputs.tag != '' | |
| env: | |
| PUSH_MAIN: ${{ steps.resolve.outputs.push_main }} | |
| TAG: ${{ steps.tag.outputs.tag }} | |
| run: | | |
| set -euo pipefail | |
| if [[ "$PUSH_MAIN" == "true" ]]; then | |
| # Fast-forward main to include the version-bump commit. | |
| git push origin "HEAD:refs/heads/main" | |
| git push origin "$TAG" | |
| else | |
| # Off-main release — only the tag is published; main is untouched. | |
| git push origin "$TAG" | |
| fi | |
| create-release: | |
| needs: cut | |
| if: needs.cut.outputs.should_release == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: refs/tags/${{ needs.cut.outputs.tag }} | |
| - name: Create draft release with bounded 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 | |
| node config/scripts/create-draft-release.mjs "$TAG" | |
| # Why: tag-scoped E2E gives release visibility, but the suite is flaky enough | |
| # that publish-release must not depend on it. | |
| e2e: | |
| needs: cut | |
| if: needs.cut.outputs.should_release == 'true' | |
| uses: ./.github/workflows/e2e.yml | |
| with: | |
| ref: refs/tags/${{ needs.cut.outputs.tag }} | |
| terminal-rendering-golden: | |
| needs: cut | |
| if: needs.cut.outputs.should_release == 'true' | |
| name: terminal rendering golden ${{ matrix.platform }} | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 30 | |
| env: | |
| NODE_OPTIONS: --max-old-space-size=4096 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - os: ubuntu-latest | |
| platform: linux | |
| - os: macos-15 | |
| platform: mac | |
| - os: windows-latest | |
| platform: windows | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: refs/tags/${{ needs.cut.outputs.tag }} | |
| - name: Install native build tools | |
| if: runner.os == 'Linux' | |
| run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version-file: package.json | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@v6 | |
| with: | |
| run_install: false | |
| # Why: Linux terminal golden E2E uses the same native install path as | |
| # release CI, which needs pnpm to bypass its non-executable gyp_main.py. | |
| - 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" | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Build Electron app for terminal rendering golden | |
| run: npx electron-vite build --mode e2e | |
| - name: Run terminal rendering golden on Linux | |
| if: runner.os == 'Linux' | |
| run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden | |
| - name: Run terminal rendering golden on macOS | |
| if: runner.os == 'macOS' | |
| run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden | |
| - name: Run terminal rendering golden on Windows | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| run: | | |
| $env:SKIP_BUILD = '1' | |
| $env:ORCA_E2E_FORWARD_APP_LOGS = '1' | |
| pnpm run test:e2e:terminal-rendering-golden | |
| - name: Upload Playwright traces | |
| if: failure() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: terminal-rendering-golden-${{ matrix.platform }}-playwright-traces | |
| path: test-results/ | |
| retention-days: 7 | |
| if-no-files-found: ignore | |
| # Why: these broader terminal rendering repros are useful release evidence, | |
| # but they include heavier app-like flows and must not block publishing. | |
| terminal-rendering-release-evidence: | |
| needs: cut | |
| if: needs.cut.outputs.should_release == 'true' | |
| continue-on-error: true | |
| name: terminal rendering release evidence ${{ matrix.platform }} | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 35 | |
| env: | |
| NODE_OPTIONS: --max-old-space-size=4096 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - os: ubuntu-latest | |
| platform: linux | |
| - os: macos-15 | |
| platform: mac | |
| - os: windows-latest | |
| platform: windows | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: refs/tags/${{ needs.cut.outputs.tag }} | |
| - name: Install native build tools | |
| if: runner.os == 'Linux' | |
| run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version-file: package.json | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@v6 | |
| with: | |
| run_install: false | |
| # Why: keep the non-blocking evidence lane on the same Linux native | |
| # install path as the blocking golden and release build jobs. | |
| - 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" | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Build Electron app for terminal rendering evidence | |
| run: npx electron-vite build --mode e2e | |
| - name: Run terminal rendering evidence on Linux | |
| if: runner.os == 'Linux' | |
| run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-release-evidence | |
| - name: Run terminal rendering evidence on macOS | |
| if: runner.os == 'macOS' | |
| run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-release-evidence | |
| - name: Run terminal rendering evidence on Windows | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| run: | | |
| $env:SKIP_BUILD = '1' | |
| $env:ORCA_E2E_FORWARD_APP_LOGS = '1' | |
| pnpm run test:e2e:terminal-rendering-release-evidence | |
| - name: Upload Playwright traces | |
| if: failure() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: terminal-rendering-release-evidence-${{ matrix.platform }}-playwright-traces | |
| path: test-results/ | |
| retention-days: 7 | |
| if-no-files-found: ignore | |
| 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: node config/scripts/ensure-native-runtime.mjs --runtime=electron && 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: 'node config/scripts/ensure-native-runtime.mjs --runtime=electron; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; 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: node config/scripts/ensure-native-runtime.mjs --runtime=electron && 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@v6 | |
| 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@v6 | |
| with: | |
| run_install: false | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v6 | |
| 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@v5 | |
| 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@v4 | |
| with: | |
| timeout_minutes: 10 | |
| max_attempts: 3 | |
| retry_wait_seconds: 30 | |
| command: pnpm install --frozen-lockfile | |
| # Why: `pnpm build:release` verifies the Linux computer-use provider by | |
| # importing AT-SPI bindings, which are runtime package deps but are not | |
| # present on stock GitHub Ubuntu release runners. | |
| # Why: `rpm` is needed by electron-builder's fpm backend to produce the | |
| # .rpm artifact. Stock Ubuntu runners do not ship it. | |
| - name: Install Linux computer-use provider dependencies | |
| if: runner.os == 'Linux' | |
| run: sudo apt-get update && sudo apt-get install -y python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool rpm | |
| - 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 }} | |
| # Why: `plutil -lint` accepts duplicate plist keys, but `codesign` | |
| # rejects duplicate entitlements after the expensive app build. | |
| - name: Verify macOS entitlements | |
| if: matrix.platform == 'mac' | |
| run: pnpm verify:macos-entitlements | |
| # Why: telemetry's transport gate (`src/main/telemetry/client.ts:IS_OFFICIAL_BUILD`) | |
| # requires the build identity to be the literal string `stable` or `rc`, | |
| # substituted by electron-vite's `define` block at build time. Derive | |
| # that identity from the release tag here — `stable` for plain semver | |
| # (`vX.Y.Z`), `rc` for prerelease (`vX.Y.Z-rc.N`). The strict regex is | |
| # a safety net: this workflow only fires on cut-tags that already match | |
| # one of those shapes, but if a future change ever loosens that, we | |
| # refuse to ship rather than let an unclassified build go out with | |
| # `BUILD_IDENTITY = null`. | |
| - name: Classify release tag for telemetry build identity | |
| id: tag-classify | |
| shell: bash | |
| env: | |
| TAG: ${{ needs.cut.outputs.tag }} | |
| run: | | |
| set -euo pipefail | |
| if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then | |
| identity=rc | |
| elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| identity=stable | |
| else | |
| echo "::error::Tag $TAG does not match stable or rc pattern; refusing to build official artifact" | |
| exit 1 | |
| fi | |
| echo "identity=$identity" >>"$GITHUB_OUTPUT" | |
| echo "Classified $TAG as $identity" | |
| # Why ORCA_POSTHOG_WRITE_KEY here: this is the only build that | |
| # produces a published binary, so this is the only place the secret | |
| # needs to be in scope. The key is a PostHog *project* API key, not | |
| # a server secret — it ships in every official binary's app.asar | |
| # and is therefore extractable from any release. We still keep it | |
| # in GitHub Actions secrets so the literal stays out of the repo | |
| # (and out of fork CI runs / log scrapers / casual greps). | |
| # Why ORCA_BUILD_IDENTITY here (not in env at the job level): the | |
| # value comes from the per-tag classification above and electron-vite | |
| # reads it from `process.env` during `pnpm build:release` only. | |
| - name: Build app | |
| run: pnpm build:release | |
| env: | |
| # Why: Vite's web build crossed Node's default old-space ceiling on | |
| # the macOS release runner, leaving v1.4.2-rc.8 as an incomplete draft. | |
| NODE_OPTIONS: --max-old-space-size=4096 | |
| ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }} | |
| ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }} | |
| # 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@v4 | |
| 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@v4 | |
| with: | |
| timeout_minutes: 30 | |
| max_attempts: 3 | |
| retry_wait_seconds: 30 | |
| command: ${{ matrix.release_command }} | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Verify release remains draft after artifact upload | |
| # Why: the build matrix must never be the actor that exposes a partial | |
| # release. If an uploader or GitHub transition flips draft early, fail | |
| # this platform leg and leave the diagnostic monitor artifact behind. | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ needs.cut.outputs.tag }} | |
| run: | | |
| set -euo pipefail | |
| releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")" | |
| # Why: release upload must validate the draft before it is publicly visible. | |
| draft="$(jq -e -r --arg tag "$TAG" ' | |
| map(select(.tag_name == $tag)) | |
| | if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end | |
| ' <<<"$releases_json")" || { | |
| echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing." | |
| exit 1 | |
| } | |
| if [[ "$draft" != "true" ]]; then | |
| echo "::error::Release $TAG was published during the ${{ matrix.platform }} artifact upload." | |
| exit 1 | |
| fi | |
| # Why post-publish (not pre-publish): electron-builder packs and | |
| # uploads in a single `--publish always` invocation, so there is no | |
| # cheap insertion point between pack and upload without splitting | |
| # that step. Running verify last still blocks the bad release: the | |
| # binary is uploaded to the draft, but a failed matrix job blocks | |
| # the `publish-release` job (which depends on `build`) from flipping | |
| # the release from draft → published, so users never see it. A human | |
| # then deletes the draft and re-cuts. | |
| # | |
| # Why this guards against: a misconfigured CI run where | |
| # `ORCA_POSTHOG_WRITE_KEY` is unset or the tag fails to classify | |
| # would otherwise produce a binary with `BUILD_IDENTITY = null` and | |
| # `WRITE_KEY = null`, which silently disables transport | |
| # (`IS_OFFICIAL_BUILD === false`) — the exact failure mode flagged | |
| # in PR #1385's deferred follow-up. | |
| - name: Verify telemetry constants present in app.asar | |
| run: node config/scripts/verify-telemetry-constants.mjs | |
| publish-release: | |
| needs: | |
| - cut | |
| - build | |
| - terminal-rendering-golden | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version-file: package.json | |
| - name: Verify release is still draft | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ needs.cut.outputs.tag }} | |
| run: | | |
| set -euo pipefail | |
| releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")" | |
| # Why: publish-release verifies the draft before making it visible. | |
| draft="$(jq -e -r --arg tag "$TAG" ' | |
| map(select(.tag_name == $tag)) | |
| | if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end | |
| ' <<<"$releases_json")" || { | |
| echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing." | |
| exit 1 | |
| } | |
| if [[ "$draft" != "true" ]]; then | |
| echo "::error::Release $TAG was published before publish-release; refusing to continue." | |
| exit 1 | |
| fi | |
| - name: Verify release assets complete | |
| # Why: publish-release is the only intended draft -> published | |
| # transition. Refuse to un-draft until every updater manifest and | |
| # referenced installer asset is present on GitHub. | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ needs.cut.outputs.tag }} | |
| run: node config/scripts/verify-release-required-assets.mjs "$TAG" | |
| - 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-published-rc-draft: | |
| needs: | |
| - cut | |
| # Why: publish-complete-draft-releases can expose a recovered RC without | |
| # running the build/publish jobs; still advance the RC cask to that tag. | |
| if: ${{ needs.cut.outputs.latest_published_rc_tag != '' }} | |
| uses: ./.github/workflows/homebrew-bump.yml | |
| with: | |
| tag: ${{ needs.cut.outputs.latest_published_rc_tag }} | |
| secrets: inherit | |
| homebrew-bump: | |
| needs: | |
| - cut | |
| - publish-release | |
| if: ${{ needs.cut.outputs.tag != '' && startsWith(needs.cut.outputs.tag, 'v') }} | |
| uses: ./.github/workflows/homebrew-bump.yml | |
| with: | |
| tag: ${{ needs.cut.outputs.tag }} | |
| secrets: inherit |