name: Cut Release # Why: single entry point for manually 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 an RC release cut without creating a tag required: false default: false type: boolean version_suffix: description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). rc kind only. required: false type: string default: '' 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: a manual RC dispatch should unstick any complete RC draft before # deciding whether to cut another tag. 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 }} VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }} run: | set -euo pipefail # Latest stable release tag, picked by *tag shape* and semver max: # - 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. # # 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:-}" # 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 if [[ -n "${VERSION_SUFFIX:-}" ]]; then # Why a dot-appended identifier (rc.N.perf): it sorts just # above its own base rc.N but BELOW rc.N+1, so suffixed side- # branch builds never outrank the main RC series and cannot # hijack the update channel; clients find them by matching the # identifier ("perf") in the prerelease components. if [[ ! "$VERSION_SUFFIX" =~ ^[0-9A-Za-z]+$ ]]; then echo "::error::version_suffix must be alphanumeric, got: $VERSION_SUFFIX" >&2 exit 1 fi new="${new}.${VERSION_SUFFIX}" 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 - name: Release E2E signal summary if: always() run: | { echo "## Release E2E Signal" echo "" echo "- Terminal rendering golden is release-blocking." echo "- Full E2E is diagnostic/non-blocking release evidence." echo "- Terminal rendering release evidence is diagnostic/non-blocking." echo "" echo "Publishing behavior is controlled by the existing job dependencies; this summary does not change release gating." } >> "$GITHUB_STEP_SUMMARY" 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 # Why: Windows terminal rendering golden is temporarily disabled on # CI while its flaky runner-only failures are investigated. # - 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: 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 # Why: Windows release evidence currently fails on CI runner PTY # readiness before reaching the rendering assertions. # - 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: # Why: windows-latest moved to the Windows 2025 / VS 2026 image before # node-gyp could detect VS 18, breaking native dependency install. - os: windows-2022 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 never' eb_cache_path: | ~\AppData\Local\electron\Cache ~\AppData\Local\electron-builder\Cache - os: ubuntu-latest platform: linux-x64 release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always eb_cache_path: | ~/.cache/electron ~/.cache/electron-builder - os: ubuntu-24.04-arm platform: linux-arm64 release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always eb_cache_path: | ~/.cache/electron ~/.cache/electron-builder runs-on: ${{ matrix.os }} # Why: hosted runners hard-cap jobs at 6h; the Windows SignPath waits # (1h inner + 4h installer) are budgeted to fit under this with the # build itself, so a slow approval can't kill the job mid-flow. timeout-minutes: 360 permissions: actions: read 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 # (winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job. - 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 # 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 # Why the optional trailing identifier: suffixed side-branch RCs # (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry # identity as plain RCs. if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; 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. # Why ORCA_DIAGNOSTICS_TOKEN_URL here: official builds pin crash # diagnostic uploads to Orca's endpoint at compile time, matching the # telemetry gate's "official binary only" behavior. - 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_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }} - name: Gate runtime file-watcher process isolation if: runner.os == 'Linux' run: | # Why: #8212 is a native-process crash contract. Prove both the Node # host and the exact Electron runtime survive SIGSEGV before packaging. node config/scripts/runtime-file-watcher-fault-harness.mjs ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs - name: Gate SSH relay watcher process isolation run: | # Why: the remote native watcher shares a daemon with live PTYs. # Kill only its child and require both PTY and watch recovery before packaging. node config/scripts/relay-watcher-fault-harness.mjs - name: Publish release artifacts (Linux) if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64' 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 }} # Why: SignPath signs GitHub workflow artifacts, so Windows builds must # upload only after the production-signed installer has been returned. - name: Build Windows release artifacts if: matrix.platform == 'win' 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 Windows node-pty ConPTY runtime if: matrix.platform == 'win' shell: pwsh run: | $runtimeDir = 'dist/win-unpacked/resources/node_modules/node-pty/build/Release' $requiredFiles = @( "$runtimeDir/conpty.node", "$runtimeDir/conpty/conpty.dll", "$runtimeDir/conpty/OpenConsole.exe" ) foreach ($file in $requiredFiles) { if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { throw "Missing Windows node-pty runtime file: $file" } Get-Item -LiteralPath $file } - name: Install SignPath PowerShell module if: matrix.platform == 'win' shell: pwsh run: | $ErrorActionPreference = 'Stop' # Why: force TLS 1.2 so gallery downloads work on older hosted images. [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 # Why: on some hosted Windows images `Register-PSRepository -Default` # fails inside the legacy nuget.exe provider with "Missing option value # for: '-source'", so PSGallery is never registered and the install # below dies with "No repository with the name 'PSGallery'". PSResourceGet # (bundled with PowerShell 7.4+) has PSGallery registered by default and # avoids that code path, so prefer it and fall back to PowerShellGet only # when it is absent. $useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue) if ($useResourceGet) { if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) { Register-PSResourceRepository -PSGallery -Trusted } else { Set-PSResourceRepository -Name PSGallery -Trusted } } else { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { Register-PSRepository -Default -InstallationPolicy Trusted } Set-PSRepository -Name PSGallery -InstallationPolicy Trusted } $trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) $documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars) $currentUserModuleRoot = $env:PSModulePath -split [System.IO.Path]::PathSeparator | Where-Object { if ([string]::IsNullOrWhiteSpace($_)) { $false } else { $candidate = [System.IO.Path]::GetFullPath($_).TrimEnd($trimChars) $candidate.StartsWith($documentsRoot, [System.StringComparison]::OrdinalIgnoreCase) } } | Select-Object -First 1 if ([string]::IsNullOrWhiteSpace($currentUserModuleRoot)) { throw 'Unable to resolve the current-user PowerShell module root from PSModulePath.' } $signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath' for ($attempt = 1; $attempt -le 3; $attempt++) { if ($attempt -eq 2) { Start-Sleep -Seconds 15 } elseif ($attempt -eq 3) { Start-Sleep -Seconds 30 } try { if ($useResourceGet) { Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop } else { Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop } Import-Module SignPath -ErrorAction Stop Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop break } catch { if ($attempt -eq 3) { throw } Write-Warning "SignPath PowerShell module preflight attempt $attempt failed: $_" if (Test-Path -LiteralPath $signPathModulePath) { Write-Warning "Removing current-user SignPath module directory before retry: $signPathModulePath" Remove-Item -LiteralPath $signPathModulePath -Recurse -Force } } } # ── Windows inner-binary signing (issue #7785) ───────────────────── # Why: SignPath cannot deep-sign inside NSIS installers, so inner PE # files (Orca.exe, node-pty *.node, DLLs) are signed via a separate zip # request, then the installer is rebuilt from the signed tree before the # existing installer signing request below. Every step in this chain is # fail-open (continue-on-error + outcome gating): any failure ships the # original installer with unsigned inner binaries, exactly like releases # did before this chain existed. Rehearsed end to end in run 28988432001 # (.github/workflows/windows-signing-rehearsal.yml). # Why: only unsigned PE files go to SignPath. Files that already carry a # valid signature (Microsoft's OpenConsole.exe) must keep their signer. - name: Stage unsigned inner PE files for signing id: stage-inner if: matrix.platform == 'win' continue-on-error: true shell: pwsh run: | $root = Resolve-Path 'dist/win-unpacked' $stage = New-Item -ItemType Directory -Force -Path 'signing-stage' $list = New-Object System.Collections.Generic.List[string] $skipped = New-Object System.Collections.Generic.List[string] Get-ChildItem -Path $root -Recurse -File | Where-Object { $_.Extension -in '.exe', '.dll', '.node' } | ForEach-Object { $relative = [System.IO.Path]::GetRelativePath($root, $_.FullName) $signature = Get-AuthenticodeSignature -FilePath $_.FullName if ($signature.Status -eq 'Valid') { $skipped.Add("$relative ") return } $destination = Join-Path $stage.FullName $relative New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null Copy-Item -Path $_.FullName -Destination $destination -Force $list.Add($relative) } if (-not ($list -contains 'Orca.exe')) { throw 'Orca.exe was not staged for signing; unpacked layout changed?' } if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) { throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.' } Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n") Write-Host "Staged $($list.Count) unsigned PE files for signing:" $list | ForEach-Object { Write-Host " $_" } Write-Host "Skipped $($skipped.Count) already-signed files:" $skipped | ForEach-Object { Write-Host " $_" } - name: Upload unsigned inner binaries for SignPath id: upload-unsigned-inner if: matrix.platform == 'win' && steps.stage-inner.outcome == 'success' continue-on-error: true uses: actions/upload-artifact@v7 with: name: orca-windows-inner-unsigned-${{ needs.cut.outputs.tag }} path: signing-stage/** if-no-files-found: error - name: Submit inner binaries signing request id: submit-inner-signing if: matrix.platform == 'win' && steps.upload-unsigned-inner.outcome == 'success' continue-on-error: true uses: signpath/github-action-submit-signing-request@v2 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0 project-slug: orca signing-policy-slug: release-signing artifact-configuration-slug: windows-inner-binaries-zip github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }} wait-for-completion: false - name: Notify Slack that inner-binary signing is waiting for approval id: notify-inner-signing if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success' continue-on-error: true shell: pwsh env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} SIGNPATH_ORGANIZATION_ID: c37aa192-a27a-4377-9c90-5d6c95912dc0 SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }} SIGNPATH_REQUEST_URL: ${{ steps.submit-inner-signing.outputs.signing-request-web-url }} TAG: ${{ needs.cut.outputs.tag }} GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) { throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.' } $requestUrl = $env:SIGNPATH_REQUEST_URL if ([string]::IsNullOrWhiteSpace($requestUrl)) { $requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID" } $message = "Orca Windows release $env:TAG inner-binaries signing request (1 of 2) is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>" $payload = @{ text = $message blocks = @( @{ type = 'section' text = @{ type = 'mrkdwn' text = $message } } ) } | ConvertTo-Json -Depth 5 Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload # Why gate on the notify outcome too: if nobody was told to approve, # don't hold the release for the approval window — fall through and # ship like today instead. The 1h wait (vs the installer's 4h) keeps # both waits plus the build inside the 360-minute job cap; missing it # falls through to today's unsigned-inner flow rather than blocking. - name: Download signed inner binaries from SignPath id: download-signed-inner if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success' continue-on-error: true shell: pwsh env: SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }} run: | Get-SignedArtifact ` -OrganizationId c37aa192-a27a-4377-9c90-5d6c95912dc0 ` -ApiToken $env:SIGNPATH_API_TOKEN ` -SigningRequestId $env:SIGNPATH_REQUEST_ID ` -OutputArtifactPath signed-inner.zip ` -Force ` -WaitForCompletionTimeoutInSeconds 3600 New-Item -ItemType Directory -Path signed-inner -Force Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force # Why: copy back strictly by the staged list so a layout mismatch in the # returned artifact fails loudly (into fail-open) instead of silently # shipping a mix of signed and unsigned binaries. - name: Restore signed inner binaries into unpacked app id: restore-signed-inner if: matrix.platform == 'win' && steps.download-signed-inner.outcome == 'success' continue-on-error: true shell: pwsh run: | $root = Resolve-Path 'dist/win-unpacked' $failures = New-Object System.Collections.Generic.List[string] foreach ($relative in Get-Content 'inner-signing-list.txt') { $signed = Get-ChildItem -Path signed-inner -Recurse -File | Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } | Select-Object -First 1 if ($null -eq $signed) { $failures.Add("missing from signed artifact: $relative") continue } $signature = Get-AuthenticodeSignature -FilePath $signed.FullName if ($null -eq $signature.SignerCertificate) { $failures.Add("returned without a signature: $relative") continue } Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject) } if ($failures.Count -gt 0) { $failures | ForEach-Object { Write-Host "::error::$_" } throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)." } # Why this step exists: electron-builder's CopyElevateHelper re-copies a # pristine elevate.exe from its download cache over resources\elevate.exe # on EVERY nsis pack — including the --prepackaged rebuild below — which # clobbered the SignPath signature in v1.4.129-rc.4. There is no supported # way to disable just the copy, so we overwrite the cache's copy with our # signed one (identical bytes plus signature) so the clobber becomes a # no-op. Known quirk: the cache persists across releases via actions/cache, # so later runs may see elevate.exe as already signed and skip staging it — # that is fine (the signature is timestamped) and the evidence gate checks # elevate.exe in the shipped installer unconditionally. If this ever causes # trouble, delete this step; the only effect is elevate.exe shipping # unsigned again, which the evidence gate will flag. - name: Replace cached elevate.exe with the signed copy id: sign-elevate-cache if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success' continue-on-error: true shell: pwsh run: | $signed = 'dist/win-unpacked/resources/elevate.exe' if (-not (Test-Path $signed)) { Write-Host '::warning::No elevate.exe in win-unpacked resources; nothing to protect from the rebuild clobber.' exit 0 } $signature = Get-AuthenticodeSignature -FilePath $signed $subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject } if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') { Write-Host "::warning::win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject); skipping cache swap." exit 0 } $cached = @(Get-ChildItem "$env:LOCALAPPDATA\electron-builder\Cache\nsis" -Recurse -Filter elevate.exe -ErrorAction SilentlyContinue) if ($cached.Count -eq 0) { Write-Host '::warning::No cached elevate.exe found (electron-builder cache layout changed?); the rebuild will pack the unsigned copy and the evidence gate will flag it.' exit 0 } foreach ($file in $cached) { Copy-Item -Path $signed -Destination $file.FullName -Force Write-Host "Replaced $($file.FullName) with the SignPath-signed copy." } - name: Rebuild NSIS installer from signed unpacked app id: rebuild-nsis-signed if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success' continue-on-error: true shell: pwsh run: | # Why: keep the pre-rebuild artifacts so a failed rebuild can fall # back to shipping them unchanged (fail-open). New-Item -ItemType Directory -Path prepack-backup -Force | Out-Null Copy-Item 'dist/orca-windows-setup.exe' 'prepack-backup/orca-windows-setup.exe' -Force Copy-Item 'dist/latest.yml' 'prepack-backup/latest.yml' -Force pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if (-not (Test-Path 'dist/orca-windows-setup.exe')) { throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe' } - name: Roll back to original installer after failed rebuild if: matrix.platform == 'win' && steps.rebuild-nsis-signed.outcome == 'failure' shell: pwsh run: | if (Test-Path 'prepack-backup/orca-windows-setup.exe') { Copy-Item 'prepack-backup/orca-windows-setup.exe' 'dist/orca-windows-setup.exe' -Force Copy-Item 'prepack-backup/latest.yml' 'dist/latest.yml' -Force Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.' } # ── End Windows inner-binary signing ─────────────────────────────── - name: Upload unsigned Windows installer for SignPath if: matrix.platform == 'win' id: upload-unsigned-windows-installer uses: actions/upload-artifact@v7 with: name: orca-windows-unsigned-${{ needs.cut.outputs.tag }} path: dist/orca-windows-setup.exe if-no-files-found: error # Why: SignPath Foundation production certificates require manual review, # so the release job waits while the signing request is approved in UI. - name: Submit Windows installer signing request id: submit-signing-request if: matrix.platform == 'win' uses: signpath/github-action-submit-signing-request@v2 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0 project-slug: orca signing-policy-slug: release-signing artifact-configuration-slug: github-actions-windows-installer github-artifact-id: ${{ steps.upload-unsigned-windows-installer.outputs.artifact-id }} wait-for-completion: false - name: Notify Slack that Windows signing is waiting for approval if: matrix.platform == 'win' shell: pwsh env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} SIGNPATH_ORGANIZATION_ID: c37aa192-a27a-4377-9c90-5d6c95912dc0 SIGNPATH_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }} SIGNPATH_REQUEST_URL: ${{ steps.submit-signing-request.outputs.signing-request-web-url }} TAG: ${{ needs.cut.outputs.tag }} GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} INNER_SIGNING_SUBMITTED: ${{ steps.submit-inner-signing.outcome == 'success' }} run: | if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) { throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.' } $requestUrl = $env:SIGNPATH_REQUEST_URL if ([string]::IsNullOrWhiteSpace($requestUrl)) { $requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID" } # Why: releases where inner signing fell through have only this one request. $stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' } $message = "Orca Windows release $env:TAG $stage is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>" $payload = @{ text = $message blocks = @( @{ type = 'section' text = @{ type = 'mrkdwn' text = $message } } ) } | ConvertTo-Json -Depth 5 Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload - name: Download signed Windows installer from SignPath if: matrix.platform == 'win' shell: pwsh env: SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} SIGNPATH_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }} run: | Get-SignedArtifact ` -OrganizationId c37aa192-a27a-4377-9c90-5d6c95912dc0 ` -ApiToken $env:SIGNPATH_API_TOKEN ` -SigningRequestId $env:SIGNPATH_REQUEST_ID ` -OutputArtifactPath signed-windows.zip ` -Force ` -WaitForCompletionTimeoutInSeconds 14400 New-Item -ItemType Directory -Path signed-windows -Force Expand-Archive -Path signed-windows.zip -DestinationPath signed-windows -Force - name: Stage signed Windows release assets if: matrix.platform == 'win' shell: pwsh run: | $signedInstaller = Get-ChildItem -Path signed-windows -Recurse -File -Filter 'orca-windows-setup.exe' | Select-Object -First 1 if ($null -eq $signedInstaller) { throw 'Signed Windows installer was not returned by SignPath.' } Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force & 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap' $installer = Get-Item 'dist/orca-windows-setup.exe' $blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap' $stream = [System.IO.File]::OpenRead($installer.FullName) try { $sha512 = [System.Security.Cryptography.SHA512]::Create() $hash = [Convert]::ToBase64String($sha512.ComputeHash($stream)) } finally { if ($null -ne $sha512) { $sha512.Dispose() } $stream.Dispose() } $latestYml = Get-Content -Path 'dist/latest.yml' -Raw $latestYml = [regex]::Replace($latestYml, '(?m)^(\s*)sha512: .+$', { param($match) "$($match.Groups[1].Value)sha512: $hash" }) $latestYml = $latestYml -replace '(?m)^ size: \d+$', " size: $($installer.Length)" $latestYml = $latestYml -replace '(?m)^ blockMapSize: \d+$', " blockMapSize: $($blockmap.Length)" Set-Content -Path 'dist/latest.yml' -Value $latestYml -NoNewline Get-Item 'dist/orca-windows-setup.exe', 'dist/orca-windows-setup.exe.blockmap', 'dist/latest.yml' - name: Verify signed Windows installer if: matrix.platform == 'win' shell: pwsh run: | $signature = Get-AuthenticodeSignature -FilePath 'dist/orca-windows-setup.exe' if ($signature.Status -ne 'Valid') { throw ($signature | Format-List * | Out-String) } if ($signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*') { throw "Unexpected Windows signer: $($signature.SignerCertificate.Subject)" } $signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint # Why: evidence gate for inner-binary signing (issue #7785, supersedes # PR #7170's Orca.exe-only gate — this covers every staged .exe/.dll/.node # by extracting the shipped installer). Warn-only until the flow has been # proven on a real release, then flip ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED # to 'true' so unsigned inner binaries block the release. - name: Verify Windows inner binary signatures if: matrix.platform == 'win' shell: pwsh env: ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'false' INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }} run: | $required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true' if ($env:INNER_SIGNING_COMPLETED -ne 'true') { $message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).' if ($required) { throw $message } Write-Host "::warning::$message" exit 0 } # Why try/catch: while the gate is warn-only, even an unexpected # script error (extraction hiccup, missing file) must not block # the release — only the flip to required makes failures fatal. try { $report = New-Object System.Collections.Generic.List[string] $failures = New-Object System.Collections.Generic.List[string] # Why: verify the files a user actually gets on disk, not the build # tree — 7z parses the NSIS exe directly as its embedded payload. $7za = 'node_modules/7zip-bin/win/x64/7za.exe' New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null & $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null $root = Resolve-Path 'inner-evidence-extract' # Why elevate.exe is always appended: staging skips already-signed # files, and the persisted electron-builder cache can carry a # previously signed elevate.exe — so it may be absent from the list # in some runs, yet it is the file most at risk of losing its # signature in the NSIS rebuild. Verify it in every release. $targets = @(Get-Content 'inner-signing-list.txt') if ($targets -notcontains 'resources\elevate.exe') { $targets += 'resources\elevate.exe' } foreach ($relative in $targets) { $path = Join-Path $root $relative if (-not (Test-Path $path)) { $failures.Add("missing from installer payload: $relative") continue } $signature = Get-AuthenticodeSignature -FilePath $path $subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject } $line = "{0,-14} {1} <{2}>" -f $signature.Status, $relative, $subject $report.Add($line) Write-Host $line if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') { $failures.Add("not signed by SignPath Foundation: $relative ($($signature.Status), $subject)") } } Set-Content -Path 'inner-signing-evidence.txt' -Value ($report -join "`n") if ($failures.Count -gt 0) { $failures | ForEach-Object { Write-Host "::warning::$_" } $message = "Windows inner-binary evidence gate found $($failures.Count) problems." if ($required) { throw $message } Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'." } else { Write-Host "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation." } } catch { if ($required) { throw } Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)." } - name: Upload Windows inner signing evidence if: always() && matrix.platform == 'win' uses: actions/upload-artifact@v7 with: name: orca-windows-inner-signing-evidence-${{ needs.cut.outputs.tag }} path: | inner-signing-evidence.txt inner-signing-list.txt if-no-files-found: ignore retention-days: 30 - name: Publish signed Windows release artifacts if: matrix.platform == 'win' uses: nick-fields/retry@v4 with: timeout_minutes: 10 max_attempts: 3 retry_wait_seconds: 30 command: gh release upload "${{ needs.cut.outputs.tag }}" "dist/orca-windows-setup.exe" "dist/orca-windows-setup.exe.blockmap" "dist/latest.yml" --clobber --repo "${{ github.repository }}" 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 for Linux: electron-builder packs and uploads in a # single `--publish always` invocation, so there is no cheap insertion # point between pack and upload without splitting those steps. Running # verify last still blocks the bad release: the binary is uploaded to the # draft, but a failed matrix job blocks `publish-release` 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 build-mac: needs: - cut - create-release if: needs.cut.outputs.should_release == 'true' # Why: SignPath requires every job in this signing workflow to be # GitHub-hosted. The actual mac build runs in release-mac-build.yml so # Blacksmith stays outside Windows artifact provenance. runs-on: ubuntu-latest permissions: actions: write contents: read steps: - name: Checkout uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version-file: package.json - name: Run isolated macOS release build run: node config/scripts/run-release-mac-build-workflow.mjs env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_MAC_BUILD_REF: ${{ github.ref_name }} RELEASE_MAC_BUILD_RELEASE_RUN_ID: ${{ github.run_id }} RELEASE_MAC_BUILD_TAG: ${{ needs.cut.outputs.tag }} RELEASE_MAC_BUILD_WORKFLOW: release-mac-build.yml publish-release: needs: - cut - build - build-mac - 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