[comment audit] Complete repository-wide comment cleanup (#33249)

* [comment audit] Align legacy failure-read counts with cached display

* [comment audit] Condense shared provider lifecycle and browser recovery comments [skip ci]

* [comment audit] Condense shared generation, path and preview comments [skip ci]

* [comment audit] Condense host, file and Git contract rationale [skip ci]

* [comment audit] Tighten Git lifecycle and provider contract comments [skip ci]

* [comment audit] Clarify shared host authority and launch isolation [skip ci]

* [comment audit] simplify local access mobile and native chat contracts [skip ci]

* [comment audit] simplify native chat presentation and state contracts [skip ci]

* [comment audit] simplify native runtime and provider contracts [skip ci]

* [comment audit] simplify migration and orchestration contracts [skip ci]

* [comment audit] simplify plugin trust and process capture rationale [skip ci]

* [comment audit] simplify terminal delivery and remote compatibility rationale [skip ci]

* [comment audit] clarify remote recovery and launch wire rationale [skip ci]

* [comment audit] shorten shared RPC compatibility and runtime evidence comments [skip ci]

* [comment audit] simplify secure storage shell startup and sidebar rationale [skip ci]

* [comment audit] clarify sidebar, skill and SSH invariants [skip ci]

* [comment audit] condense structured chat delivery and timing rationale [skip ci]

* [comment audit] condense terminal replay and telemetry invariants [skip ci]

* [comment audit] clarify launch permission and terminal delivery rationale [skip ci]

* [comment audit] clarify workspace ownership and layout contracts [skip ci]

* [comment audit] wait for fitted and painted SSH terminal links

* [comment audit] clarify layout persistence and session ownership [skip ci]

* [comment audit] clarify host routing and workspace lifecycle contracts [skip ci]

* [comment audit] clarify startup persistence and app lifetime comments [skip ci]

* [comment audit] clarify recovery and activity boundaries [skip ci]

* [comment audit] condense activity and artifact invariants [skip ci]

* [comment audit] condense automation authority contracts [skip ci]

* [comment audit] simplify automation scheduling and history contracts [skip ci]

* [comment audit] clarify browser chrome and annotation lifecycles [skip ci]

* [comment audit] clarify browser routing and retained guest ownership [skip ci]

* [comment audit] clarify browser recovery and document ownership comments [skip ci]

* [comment audit] restore hosted runners required by native IME fixtures

* [comment audit] shorten preview, palette and dashboard invariants [skip ci]

* [comment audit] clarify terminal preview, dashboard and retention comments [skip ci]

* [comment audit] clarify diff-note recovery and editor lifecycle comments [skip ci]

* [comment audit] shorten rich-text and combined-diff ownership comments [skip ci]

* [comment audit] shorten autosave, draft recovery and model ownership comments [skip ci]

* [comment audit] shorten save-queue, notebook and Markdown constraints [skip ci]

* [comment audit] shorten Monaco, PDF and rich Markdown safeguards [skip ci]

* [comment audit] clarify rich Markdown source and selection safeguards [skip ci]

* [comment audit] clarify editor loading and emulator stream lifetimes [skip ci]

* [comment audit] trim emulator and feature tour narration [skip ci]

* [comment audit] clarify floating workspace and GitHub cache ownership [skip ci]

* [comment audit] trim provider controls and Linear filter explanations [skip ci]

* [comment audit] clarify pairing and chat resume lifetimes [skip ci]

* [comment audit] clarify chat delivery and transcript lifetimes [skip ci]

* [comment audit] clarify session options and host-owned status [skip ci]

* [comment audit] clarify draft durability and live-status precedence [skip ci]

* [comment audit] Trim chat send and model ownership explanations [skip ci]

* [comment audit] Shorten transcript ownership and send recovery comments [skip ci]

* [comment audit] Simplify native chat composer and transcript hook comments [skip ci]

* [comment audit] Shorten native chat scroll and structured session explanations [skip ci]

* [comment audit] Trim workspace picker and onboarding commentary [skip ci]

* [comment audit] Simplify pet rendering, review cache and quick open comments [skip ci]

* [comment audit] Trim sidebar and session history explanations [skip ci]

* [comment audit] Condense session resume and review checks comments [skip ci]

* [comment audit] Shorten checks state and file explorer refresh comments [skip ci]

* [comment audit] Trim plugin bridge and source-control action rationale [skip ci]

* [comment audit] Trim source-control listing and review rationale [skip ci]

* [comment audit] Shorten sidebar lifecycle and explorer safeguards [skip ci]

* [comment audit] Condense Settings account and appearance rationale [skip ci]

* [comment audit] Trim pairing and repository Settings explanations [skip ci]

* [comment audit] Condense Settings search SSH and terminal safeguards [skip ci]

* [comment audit] Shorten Settings voice and provider ownership comments [skip ci]

* [comment audit] Condense Settings draft persistence and host-state rationale [skip ci]

* [comment audit] Trim Settings hooks and sidebar setup rationale [skip ci]

* [comment audit] Condense sidebar deletion and board-filter safeguards [skip ci]

* [comment audit] Condense worktree cards, host ownership and Markdown contracts [skip ci]

* [comment audit] Condense sidebar drag, folder and filtering contracts [skip ci]

* [comment audit] Condense sidebar parity, sleep and host ownership contracts [skip ci]

* [comment audit] Prune focus-read baseline after comment cleanup

* [comment audit] Clarify sidebar visibility, board drops and agent evidence [skip ci]

* [comment audit] Tighten sidebar listing, drag and reveal contracts [skip ci]

* [comment audit] Clarify viewport geometry and skill update safeguards [skip ci]

* [comment audit] Tighten skill inventory, usage and host status comments [skip ci]

* [comment audit] Clarify usage ownership and resource kill safeguards [skip ci]

* [comment audit] Tighten tab selection, rename and search contracts [skip ci]

* [comment audit] Tighten tab-strip geometry and drag ownership comments [skip ci]

* [comment audit] Tighten Tasks recovery, pagination and mutation comments [skip ci]

* [comment audit] Tighten provider loading and terminal recovery comments [skip ci]

* [comment audit] Tighten completion, cadence and PTY ownership comments [skip ci]

* [comment audit] Clarify terminal restore and parked-pane boundaries [skip ci]

* [comment audit] Tighten PTY startup and replay explanations [skip ci]

* [comment audit] Clarify PTY identity and delivery ownership [skip ci]

* [comment audit] Shorten remote reconnect and input replay rationale [skip ci]

* [comment audit] Condense terminal parking clipboard and file link comments [skip ci]

* [comment audit] Clarify IME ownership and trim trace history [skip ci]

* [comment audit] Shorten pane recovery and parked watcher explanations [skip ci]

* [comment audit] Condense snapshot replay and visibility recovery rationale [skip ci]

* [comment audit] Shorten terminal parking and keyboard ownership rationale [skip ci]

* [comment audit] Trim renderer lifecycle and menu rationale [skip ci]

* [comment audit] Condense workspace cleanup ownership and settlement notes [skip ci]

* [comment audit] Clarify renderer hook evidence and source ownership [skip ci]

* [comment audit] Tighten IPC ownership and reconnect explanations [skip ci]

* [comment audit] Trim renderer focus, paste and attention comments [skip ci]

* [comment audit] Shorten scroll, locale and agent lifecycle comments [skip ci]

* [comment audit] Trim agent launch, resume and ownership commentary [skip ci]

* [comment audit] Shorten account, browser and editor comments [skip ci]

* [comment audit] Clarify host-mirror waits, previews and input ownership [skip ci]

* [comment audit] Condense launch recovery and editor tokenization comments [skip ci]

* [comment audit] Condense workspace selection and terminal pane rationale [skip ci]

* [comment audit] Condense terminal fitting and rendering recovery comments [skip ci]

* [comment audit] Condense terminal input and host routing explanations [skip ci]

* [comment audit] Condense ownership and sleeping-session recovery rationale [skip ci]

* [comment audit] Condense structured launch and terminal link explanations [skip ci]

* [comment audit] Condense terminal ownership and workspace file routing rationale [skip ci]

* [comment audit] Condense session persistence and workspace activation rationale [skip ci]

* [comment audit] Condense workspace routing and browser ownership rationale [skip ci]

* [comment audit] Condense mirror recovery and layout authority rationale [skip ci]

* [comment audit] Condense runtime transport and file safety rationale [skip ci]

* [comment audit] Condense runtime graph and structured session rationale [skip ci]

* [comment audit] Condense browser creation and remote tab recovery rationale [skip ci]

* [comment audit] Condense snapshot authority and publisher recovery rationale [skip ci]

* [comment audit] Condense frame, startup and provider cache rationale [skip ci]

* [comment audit] Condense host twin and agent lifecycle rationale [skip ci]

* [comment audit] Condense browser authority and editor cleanup rationale [skip ci]

* [comment audit] Condense editor buffer identity and review cache rationale [skip ci]

* [comment audit] Condense provider lifetime and repository ownership rationale [skip ci]

* [comment audit] Condense reconnect, hydration and terminal retirement rationale [skip ci]

* [comment audit] Condense recovery, reconciliation and persistence rationale [skip ci]

* [comment audit] Condense cleanup ownership and listing admission rationale [skip ci]

* [comment audit] Condense teardown ordering and terminal ownership rationale [skip ci]

* [comment audit] Condense recovery ledger and web host-boundary rationale [skip ci]

* [comment audit] Condense web reconnect and credential lifecycle rationale [skip ci]

* [comment audit] Condense cloud rollout and assignment safety rationale [skip ci]

* [comment audit] Condense relay session and database recovery rationale [skip ci]

* [comment audit] Condense relay placement and protocol compatibility rationale [skip ci]

* [comment audit] Condense build and packaging constraints [skip ci]

* [comment audit] Condense CI gates and validation isolation rationale [skip ci]

* [comment audit] Condense catalog identity and browser validation rationale [skip ci]

* [comment audit] Condense mobile browser and frame-budget rationale [skip ci]

* [comment audit] Shorten mobile route and browser test explanations [skip ci]

* [comment audit] Condense editor and session seam test rationale [skip ci]

* [comment audit] Trim session and terminal closure history [skip ci]

* [comment audit] Shorten terminal browser and census explanations [skip ci]

* [comment audit] Preserve root-only E2E diagnostic baseline

* [comment audit] Condense mobile seam and HTML preview test rationale [skip ci]

* [comment audit] Condense preview instrumentation and render harness rationale [skip ci]

* [comment audit] Condense bundle contracts and native-build constraints [skip ci]

* [comment audit] Shorten native packaging and CI routing rationale [skip ci]

* [comment audit] Condense release tooling and benchmark constraints [skip ci]

* [comment audit] Condense packaging and platform gate rationale [skip ci]

* [comment audit] Shorten mobile route and history rationale [skip ci]

* [comment audit] Condense mobile browser and component rationale [skip ci]

* [comment audit] Shorten drawer and workspace-form rationale [skip ci]

* [comment audit] Trim mobile review and editor-document explanations [skip ci]

* [comment audit] Condense editor lifecycle and Markdown explanations [skip ci]

* [comment audit] Trim mobile file, diagnostics and dictation commentary [skip ci]

* [comment audit] Simplify mobile host-list and workspace-view explanations [skip ci]

* [comment audit] Clarify mobile shell lifetime and bridge delivery constraints [skip ci]

* [comment audit] Condense bridge stream, native request and audio rationale [skip ci]

* [comment audit] Reduce bridge schema and page-client commentary [skip ci]

* [comment audit] Tighten mobile page bridge contracts [skip ci]

* [comment audit] Clarify mobile cache and session constraints [skip ci]

* [comment audit] Condense mobile route and lifecycle rationale [skip ci]

* [comment audit] Tighten mobile shell hook timing rationale [skip ci]

* [comment audit] Clarify route handoff and notification compatibility [skip ci]

* [comment audit] Clarify push registration and dictation cleanup constraints [skip ci]

* [comment audit] Condense keyboard geometry and native media ownership rationale [skip ci]

* [comment audit] Tighten mobile chat scroll and composer concurrency rationale [skip ci]

* [comment audit] Condense mobile reply compatibility and upload ownership rationale [skip ci]

* [comment audit] Condense optimistic echo and scroll ownership rationale [skip ci]

* [comment audit] Tighten stream replay and queued session ownership rationale [skip ci]

* [comment audit] Condense mobile composer and reply-schema constraints [skip ci]

* [comment audit] Clarify mobile transcript recovery and session ownership [skip ci]

* [comment audit] Clarify mobile queue and source-control compatibility [skip ci]

* [comment audit] Condense storage and task launch constraints [skip ci]

* [comment audit] Clarify task schemas and terminal document ownership [skip ci]

* [comment audit] Tighten terminal input and viewport rationale [skip ci]

* [comment audit] Shorten terminal and RPC recorder contracts [skip ci]

* [comment audit] Condense recorder engine and connection contracts [skip ci]

* [comment audit] Shorten host credential and relay recovery comments [skip ci]

* [comment audit] Tighten pairing recovery and bundle transport rationale [skip ci]

* [comment audit] Clarify RPC delivery and workspace catalog boundaries [skip ci]

* [comment audit] Condense catalog and relay entry contracts [skip ci]

* [comment audit] Shorten shutdown and SSH relay delivery contracts [skip ci]

* [comment audit] Condense WSL relay and E2E regression rationale [skip ci]

* [comment audit] Shorten cross-version harness and renderer fixture explanations [skip ci]

* [comment audit] Condense paired-host and E2E lifecycle rationale [skip ci]

* [comment audit] Shorten layout oracle and regression explanations [skip ci]

* [comment audit] Condense paired recovery and ownership regression contracts [skip ci]

* [comment audit] Shorten restart, sidebar and SSH lifecycle contracts [skip ci]

* [comment audit] Condense terminal rendering and IME regression rationale [skip ci]

* [comment audit] Shorten terminal recovery and benchmark contracts [skip ci]

* [comment audit] Condense tooling and Windows lifecycle proof rationale [skip ci]

* [comment audit] Condense workflow routing and release rationale [skip ci]

* [comment audit] Condense mobile security and installer contracts [skip ci]

* [comment audit] Shorten native rationale and stylesheet workarounds [skip ci]

* [comment audit] Shorten Windows visibility probe rationale [skip ci]

* [comment audit] Condense embedded release and signing rationale [skip ci]

* [comment audit] Retain current main test behavior in final reconciliation [skip ci]

* [comment audit] Complete final comment review across current source inventory

* [comment audit] Handle consolidated audit reports before final CI

* [comment audit] Preserve credential storage census detail [skip ci]

* [comment audit] Scale broad changed E2E selections with verified timing shards

* [comment audit] Align E2E assertions with browser close and settled session metadata

* [comment audit] Match existing E2Es to current resource and windowing contracts

* [comment audit] Observe the shared server outage notice in reconnect coverage

* [comment audit] Make existing renderer probes measure settled tab and PTY work

* [comment audit] Align palette E2E selector with task search

* [comment audit] Refresh remaining palette search selectors

* [comment audit] Keep selector correction within selected audit specs

* [comment audit] Await emoji fixture settings inside renderer evaluation

* [comment audit] Cancel fixture fit retries between terminal tests

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-25 04:05:46 -07:00
committed by GitHub
co-authored by OrcaWin
parent aa08c84f3b
commit 7d6603aa17
16196 changed files with 51015 additions and 179882 deletions
+38 -171
View File
@@ -1,58 +1,12 @@
name: Adhoc macOS + Windows Dev Build
# Why: lets anyone cut installable macOS and Windows builds of a trusted repo ref
# so the team can run an experimental feature instead of reasoning from a diff.
# Hourly covers current main; this covers experiments and on-demand validation.
#
# Deliberately narrow scope:
# - macOS and Windows desktop installers. Linux keeps using RC/stable.
# - No tests, no lint, no e2e. PR CI and release-cut remain the gates. The one
# exception is the orcad template: like release-cut, it is merged from the
# node-server lanes that build and qualify each SSH target's slot.
# - macOS is signed and notarized so TCC grants survive updates.
# - Windows is unsigned; the published release notes explain the one-time
# SmartScreen/manual-install requirement.
#
# Artifacts publish to stablyai/orca-adhoc — separate from both orca and
# orca-hourly. Separate from orca because the main repo's releases atom feed
# exposes only its 10 newest entries. Separate from orca-hourly because a build
# from someone's branch must never be picked up by a developer who only meant to
# ride main; the two are different levels of "unvetted".
#
# From the Actions tab: pick this workflow, "Run workflow", leave "Use workflow
# from" on main, and type your branch in the first field. Or from the CLI:
#
# gh workflow run adhoc-mac-build.yml --ref main -f ref=my-branch -f label=wasm-terminal
#
# Leaving the ref field empty builds whatever "Use workflow from" is set to, which
# is what someone who only touched that picker means. Naming the branch explicitly
# is still better: the workflow file is always read from the dispatch ref, so a
# branch carrying a stale copy of this file would otherwise run that copy.
#
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use the
# same GitHub App as hourly, additionally installed on orca-adhoc with
# Contents: Read and write. The secret names below are historical — one App, one
# private key, both dev-channel repos — and rotating it stays a single operation.
# Provision with `bash config/scripts/setup-hourly-release-token.sh`.
#
# The requested ref is vetted before checkout: it must be a branch or tag of this
# repo, or a commit reachable from one. PR refs are refused outright — this
# workflow runs the checked-out code next to MAC_CERTS and the notary password,
# so "just build that community PR" must not become a way to hand fork code the
# release identity. A branch here always belongs to someone with write access,
# which is the same trust the dispatch button itself already requires.
# Build trusted repo refs on demand: signed/notarized macOS and unsigned Windows, with qualified orcad slots; PR/release pipelines own other gates. Publish to orca-adhoc so branch experiments cannot enter main’s hourly channel or crowd stable/RC release feeds. Dispatch main’s workflow with -f ref=my-branch to avoid stale YAML. Writes use the hourly GitHub App installed on orca-adhoc (setup-hourly-release-token.sh); GITHUB_TOKEN cannot publish cross-repo. Ref vetting rejects PR refs and unreachable SHAs before source runs beside signing secrets.
on:
workflow_dispatch:
inputs:
ref:
# Why optional: the Actions UI already shows its own "Use workflow from"
# branch picker directly above this field, and picking a branch there is
# what most people will read as "build this". Defaulting to that branch
# makes the obvious action correct. Fill this in only to build a ref other
# than the one the workflow file itself is read from — normally leave the
# picker on main and name your branch here, so a stale copy of this
# workflow on an old branch is not what runs.
# Default empty ref to the dispatch picker; prefer main’s workflow plus explicit branch input to build old branches with current YAML.
description: 'Branch, tag, or SHA to build — must live in stablyai/orca; PR refs are refused (default: the branch selected above)'
required: false
default: ''
@@ -67,27 +21,18 @@ permissions:
contents: read
concurrency:
# Why keyed on the ref rather than global: two people cutting builds from two
# different branches at the same time is the ordinary case here, and serialising
# them would make each wait out the other's notary queue. Re-dispatching the
# *same* branch still queues, so a push mid-build cannot race itself.
# Serialize per ref: queue repeat builds while independent branches avoid each other’s notary wait.
group: adhoc-mac-build-${{ inputs.ref || github.ref_name }}
cancel-in-progress: false
env:
ORCA_BACKGROUND_LAUNCH: '1'
ADHOC_REPO: stablyai/orca-adhoc
# Why age and not a count like hourly: this channel is low-volume and bursty, so
# a count would either hold one week's experiments forever or evict a build
# someone is still running after a busy afternoon. A month is well past the "few
# days" these exist for, and by then the branch has landed or been abandoned.
# Retain by month rather than count so bursts cannot evict recent experiments or sparse periods keep them forever.
ADHOC_RETAIN_DAYS: 30
jobs:
# Why: every job below checks out its own copy, and a branch name read per job builds whatever
# the branch points at when that job starts. A push mid-run then mixes commits, e.g. slot lanes
# from one commit merged by a template step from the next. Resolving once pins the whole run.
# Validate reachability before any job executes the requested source.
# Resolve once before source execution so concurrent pushes cannot mix slot/template/desktop commits.
resolve-ref:
if: github.repository == 'stablyai/orca'
runs-on: blacksmith-2vcpu-ubuntu-2404
@@ -111,17 +56,12 @@ jobs:
exit 1
;;
esac
# Bare: a work-tree repo refuses to fetch over its own checked-out
# branch. tree:0 keeps the fetch to the commit graph — no trees, no
# blobs — so this stays cheap next to the build it fronts. reftable
# because this repo has branches that differ only in casing, and the
# files backend cannot store both on a case-insensitive runner disk —
# it fails the entire fetch, not just the one ref.
# Bare avoids fetching over a checked-out branch; tree:0 fetches only the commit graph.
# Reftable preserves case-only branch names that make the files backend fail on case-insensitive disks.
scratch="$RUNNER_TEMP/vet-requested-ref"
git init -q --bare --ref-format=reftable "$scratch"
git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
# Branch first to keep actions/checkout's old tie-break: bare
# rev-parse would prefer the tag when a branch shares its name.
# Resolve branches before tags, matching actions/checkout’s tie-break.
sha=""
case "$REQUESTED_REF" in
refs/heads/*|refs/tags/*) candidates=("$REQUESTED_REF") ;;
@@ -143,10 +83,7 @@ jobs:
echo "::error::'$REQUESTED_REF' does not resolve to a branch, tag, or commit of stablyai/orca."
exit 1
fi
# The object resolving locally is not proof a branch or tag reaches
# it: a partial clone can lazily fetch a bare SHA on demand, and
# GitHub serves PR-only commits by SHA. Reachability is the actual
# trust test.
# Lazy-fetchable SHAs can be PR-only; require reachability from a trusted branch/tag, not local object presence.
if [[ -z "$(git -C "$scratch" for-each-ref --count=1 --format='%(refname)' --contains "$sha" refs/heads refs/tags)" ]]; then
echo "::error::Commit $REQUESTED_REF is not reachable from any branch or tag of stablyai/orca; refusing to build it with release credentials."
exit 1
@@ -212,20 +149,15 @@ jobs:
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Why this check: the version scripts are read from the branch being built,
# not from main, so a branch cut before the adhoc channel landed has no
# copy of them. Say that plainly instead of failing with a module-not-found.
# Old branch refs may predate adhoc version scripts; report that explicitly.
for script in adhoc-build-version dev-channel-base-version; do
if [[ ! -f "config/scripts/$script.mjs" ]]; then
echo "::error::$REF has no config/scripts/$script.mjs; rebase it onto a main that has the adhoc channel."
exit 1
fi
done
# Why git tags, not GitHub releases: unpublishing a buggy cut deletes the
# GitHub release and leaves the tag, which still owns that number.
# Releases-only let adhoc sit on a number already taken, so the updater
# would not install it. Empty on failure — the script then falls back
# to package.json.
# Unpublishing deletes releases but leaves version-owning tags; use tags to prevent updater regression.
# On lookup failure the script falls back to package.json.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \
"repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \
--jq '.[].ref | sub("^refs/tags/"; "")' || true)"
@@ -238,10 +170,7 @@ jobs:
fi
cat "$RUNNER_TEMP/adhoc-identity.txt" >>"$GITHUB_OUTPUT"
# Design D2, as release-cut does: every desktop build ships the orcad template (server JS plus
# every target's addons), merged from the node-server lanes that qualify each slot at this ref.
# Without it an adhoc build cannot deploy managed orcad to an SSH host. No secrets, and the mac
# job vets the ref before anything is signed.
# Ship managed-SSH orcad template from qualified per-target lanes at the vetted ref, as release-cut does.
orcad-template:
needs: [resolve-ref, orcad-template-support]
if: needs.orcad-template-support.outputs.ships == 'true'
@@ -293,22 +222,13 @@ jobs:
NAME: ${{ needs.orcad-template-support.outputs.name }}
SHA: ${{ needs.resolve-ref.outputs.sha }}
REF: ${{ inputs.ref || github.ref_name }}
# Via env, not inline `${{ }}`: both land inside a shell string, and an
# expression expanded there is substituted before bash parses the line
# (zizmor: template-injection).
# Pass expressions via env so bash parses data rather than substituted script text (zizmor: template-injection).
ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
short_sha="${SHA:0:12}"
# Why create it up front: electron-builder then uploads into a known tag
# rather than inferring one from package.json.
#
# Why --draft: everything between here and the manifest check is a window
# where the release exists but has no installable assets. A draft is
# absent from the releases list and from listReleaseBuilds, so a job that
# dies in that window — including a hard kill by the job timeout, which
# runs no cleanup step at all — leaves something invisible rather than a
# tag the picker offers and the download 404s on.
# Precreate a known tag as draft so builder cannot infer it from package.json.
# Keep incomplete assets invisible until manifest verification, even if timeout kills the job without cleanup.
gh release create "$TAG" \
--repo "$ADHOC_REPO" \
--title "$NAME" \
@@ -337,13 +257,7 @@ jobs:
needs.orcad-template-support.result == 'success' &&
needs.release-javascript.result == 'success' &&
needs.prepare-adhoc-mac-release.result == 'success'
# Why an environment: it gives the signing/notary/App secrets somewhere to
# live that a stale copy of this workflow on an old branch cannot reach.
# Referencing it is a no-op until repo settings give it teeth; the intended
# follow-up is to move MAC_CERTS, MAC_CERTS_PASSWORD, APPLE_ID,
# APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID, HOURLY_RELEASE_APP_ID, and
# HOURLY_RELEASE_APP_PRIVATE_KEY into it, then pin its deployment branch
# policy to main so only main's copy of this file can read them.
# Signing environment must restrict deployments to main after MAC_CERTS/password, APPLE_ID/app password/team and hourly App secrets move there; referencing it alone enforces nothing.
environment: adhoc-mac-build
permissions:
contents: read
@@ -353,21 +267,13 @@ jobs:
matrix:
arch: [x64, arm64]
runs-on: blacksmith-12vcpu-macos-15
# Why 150: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
# the job is killed mid-retry and no cleanup step runs at all.
# Allow 150min for install 3×10 + publish 2×45 + ~25 setup/build/verify; outer timeout would prevent cleanup.
timeout-minutes: 150
env:
JOBS: max
NODE_OPTIONS: --max-old-space-size=4096
steps:
# Why vet before checkout: everything after this step runs the checked-out
# code with release signing credentials in reach. Branches and tags of this
# repo are the intended audience; refs/pull/* would smuggle in fork code,
# and a raw SHA is only accepted when some branch or tag of this repo can
# actually reach it. Resolving to a pinned SHA here also means the commit
# that was vetted is the commit that gets checked out — a push to the
# branch between the two steps cannot swap it.
# Vet branch/tag reachability and reject refs/pull before checkout can access signing credentials; checkout the resolved SHA to prevent ref-swap races.
- name: Vet the requested ref
id: vetted
shell: bash
@@ -380,17 +286,11 @@ jobs:
- name: Checkout the requested ref
uses: actions/checkout@v6
with:
# Why an input at all rather than just github.ref: the whole point is to
# build code that has not landed, and the workflow definition itself
# always comes from the dispatch ref — naming the branch here instead
# applies main's current copy of this file to an arbitrary branch.
# Explicit source input lets main’s workflow build an unmerged branch rather than run that branch’s stale workflow.
ref: ${{ steps.vetted.outputs.sha }}
# Version helpers only read HEAD; published versions come from the release API.
fetch-depth: 1
# This job only reads stablyai/orca and never pushes; every write goes
# to the adhoc repo through a minted App token passed by env. Not
# persisting the checkout credential shrinks the blast radius if a build
# step is compromised (zizmor: artipacked).
# Read this repo without persisted checkout credentials; all writes use an env-passed App token for orca-adhoc (zizmor: artipacked).
persist-credentials: false
- name: Setup pnpm
@@ -408,8 +308,7 @@ jobs:
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# Why: packaging bundles the pinned Node into the macOS terminal host. Keyed on the pin alone
# with no restore-keys, so a pin bump starts empty instead of carrying old archives forward.
# Cache bundled host Node by exact pin, without restore keys, so bumps cannot restore old archives.
- name: Locate the pinned Node runtime cache
shell: bash
run: echo "ORCA_NODE_RUNTIME_CACHE_DIR=$HOME/Library/Caches/orca-node-runtime" >>"$GITHUB_ENV"
@@ -430,8 +329,7 @@ jobs:
restore-keys: |
electron-builder-mac-
# Why both CPUs: the mac config packages x64 and arm64 from this arm64
# runner, so the install must carry both variants of the native optional deps.
# Install both x64/arm64 optional natives because this arm64 runner packages both.
- name: Install dependencies
uses: nick-fields/retry@v4
with:
@@ -440,13 +338,11 @@ jobs:
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile --cpu=current,x64,arm64
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules.
# Build required out/mobile-web after installing its React Native/Expo dependencies.
- uses: ./.github/actions/install-mobile-dependencies
if: needs.release-javascript.outputs.supported != 'true'
# Why: signing is what makes an adhoc build installable over an existing
# Orca, so a missing cert must fail here rather than after a 20-minute build.
# Require signing cert before expensive build; unsigned macOS cannot update an existing install correctly.
- name: Verify macOS signing environment
run: node config/scripts/verify-macos-release-env.mjs
env:
@@ -475,9 +371,7 @@ jobs:
ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA: ${{ needs.release-javascript.outputs.supported == 'true' && needs.release-javascript.outputs.source_sha || '' }}
ORCA_RELEASE_JAVASCRIPT_BUILD_IDENTITY: dev
NODE_OPTIONS: --max-old-space-size=4096
# Why: adhoc builds are not an official channel — telemetry's transport
# gate accepts only 'stable' or 'rc', so leaving this unset keeps them
# silent, which is correct for unvetted branch artifacts.
# Leave build identity unset: telemetry accepts only official stable/rc, so branch artifacts stay silent.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
- name: Wait for the qualified orcad template
@@ -512,10 +406,7 @@ jobs:
name: orcad-template
path: out/orcad-template
# Why the token is minted here and not at the top: installation tokens live
# one hour, everything before this point writes nothing, and the notary round
# trip inside the publish step can be tens of minutes. Minting after the build
# starts the clock at the first call that actually uses it.
# Mint the one-hour write token after build, just before potentially long notarization/publish.
- name: Checkout workflow packaging scripts
uses: actions/checkout@v6
with:
@@ -528,9 +419,7 @@ jobs:
- name: Package and notarize this Mac architecture
uses: nick-fields/retry@v4
with:
# Why 45: an attempt is pack + notarize, and the notary queue is
# the unbounded part. Two attempts, because a failed adhoc build has a
# person waiting on it who can simply dispatch again.
# Allow two 45min pack/notarize attempts; an adhoc user can redispatch rather than pay for a third.
timeout_minutes: 45
max_attempts: 2
retry_wait_seconds: 30
@@ -542,9 +431,7 @@ jobs:
ORCA_REQUIRE_ORCAD_TEMPLATE: ${{ needs.orcad-template-support.outputs.ships == 'true' && '1' || '' }}
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
# Why all three: electron-builder's notarize step authenticates to the
# Apple notary service with the app-specific password, not with the
# signing cert. Omitting them fails the build rather than skipping it.
# Require Apple id/app-specific password/team: notarization authenticates separately from signing cert.
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
@@ -677,9 +564,7 @@ jobs:
}
throw new Error('Timed out waiting for the qualified orcad template')
# Why this is the last mutating step: publishing the draft is what makes the
# build visible to listReleaseBuilds. Doing it only after the manifest check
# means the picker can never offer a release whose assets are incomplete.
# Publish only after manifest validation so the picker never offers incomplete assets.
- name: Publish the verified release
id: publish_live
shell: bash
@@ -689,18 +574,11 @@ jobs:
NAME: ${{ needs.orcad-template-support.outputs.name }}
run: |
set -euo pipefail
# --title again: electron-builder resolves this draft by tag and may
# rewrite its title on upload. Re-asserting here means the name the
# picker reads is the one composed above, whatever it did in between.
# Reassert the picker title after electron-builder may rewrite the tag’s draft on upload.
gh release edit "$TAG" --repo "$ADHOC_REPO" --draft=false --prerelease --title "$NAME"
echo "Published $TAG as \"$NAME\""
# Why: a draft left behind by a failed publish is invisible to users but still
# holds its tag name. Gated on publish_live not having succeeded so a later
# failure (the prune step) cannot delete a release that already went live and
# that people may already be installing. Why cancelled() too: a run stopped
# from the Actions UI is not a failure(), so without it a manual cancel
# mid-publish would strand the draft.
# Delete stranded drafts on failure/cancel, but never after publish_live: later prune failure must not remove an installable release.
- name: Discard the draft release on failure
if: >-
(failure() || cancelled()) && needs.prepare-adhoc-mac-release.outputs.tag != '' &&
@@ -717,32 +595,21 @@ jobs:
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune expired adhoc releases
# Only after a live publish: $TAG is then a non-draft this step must not
# delete, and a run that failed before publishing has nothing to retire.
# Retire only after live publication; preserve this non-draft tag and leave failed runs to draft cleanup.
if: steps.publish_live.outcome == 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
# Protect the tag this run just shipped, so no filter mistake can delete
# a build minutes after the person who cut it was told it exists.
# Exclude the just-published tag from pruning even if ordering/filter logic regresses.
TAG: ${{ needs.prepare-adhoc-mac-release.outputs.tag }}
run: |
set -euo pipefail
# Why compute the cutoff in bash rather than with jq's `now`: this runs
# once per dispatch, and a fixed epoch makes the threshold visible in the
# log when someone asks where their build went.
# Fix/log the cutoff epoch once per dispatch so retention decisions remain auditable.
cutoff=$(( $(date -u +%s) - ADHOC_RETAIN_DAYS * 86400 ))
echo "Pruning adhoc releases published before $(date -u -d "@$cutoff" '+%Y-%m-%dT%H:%M:%SZ')"
# --cleanup-tag so pruning does not leave orphan tags with no release or
# assets attached. Drafts are excluded: a stale draft is the failure
# path's business, not the retention window's.
#
# Age comes from publishedAt, never createdAt. GitHub reports createdAt
# as the date of the *commit* a release's tag points at, and every tag
# here is cut from this repo's one seed commit — so all of them carry
# that same createdAt, and the day the window rolled past it the entire
# channel expired at once and a single run deleted it. A release with no
# publishedAt is kept rather than aged by guesswork.
# --cleanup-tag removes release-less tags; failure cleanup owns drafts, which retention excludes.
# Use publishedAt: GitHub’s createdAt is the seed commit’s date for every tag. Aging by it once
# expired the entire channel in one run; retain releases with unknown publishedAt.
jq_filter='map(select(.isDraft | not))'
if [[ -n "${TAG:-}" ]]; then
jq_filter+=" | map(select(.tagName != \"${TAG//\"/\\\"}\"))"
@@ -1,13 +1,6 @@
name: Publish agent state rules
# The only publisher of agent state rules releases. Merging never publishes: a maintainer runs
# this from main, first to `next` (RC and dev builds), then promotes the identical file to
# `stable` after a soak. Every job runs on the dispatched commit, so the publish job runs exactly
# the script and rules the gate tested.
#
# Why GITHUB_TOKEN: release-policy.yml deletes an agent-state-rules-* release any other author
# publishes. Its releases start no workflow, so release-policy.yml sees them only when someone
# edits one by hand, and then accepts them as bot-authored prereleases.
# Maintainer-only publisher: dispatch main to next, soak, then promote identical bytes to stable. All jobs use the dispatched commit. Use GITHUB_TOKEN so release-policy accepts bot-authored prereleases; token-created releases do not trigger workflows until manual edits.
on:
workflow_dispatch:
inputs:
+2 -5
View File
@@ -80,8 +80,7 @@ jobs:
- wait: history-scan
# Compiles the workspace. No Postgres service: nothing here reaches a
# database, and the service container costs ~13s of startup.
# Compile without Postgres: no database access occurs here, avoiding ~13s service startup.
build:
if: github.event_name != 'pull_request' || github.event.pull_request.draft != true
runs-on: blacksmith-4vcpu-ubuntu-2204
@@ -167,9 +166,7 @@ jobs:
path: cloud/security-reports/
retention-days: 30
# Runs in parallel with build. `pnpm test` compiles the workspace
# packages it needs through pretest hooks, so it does not depend on
# `pnpm build` having run.
# Run tests beside build; pretest hooks compile their own workspace dependencies.
test:
if: github.event_name != 'pull_request' || github.event.pull_request.draft != true
runs-on: blacksmith-4vcpu-ubuntu-2204
+5 -16
View File
@@ -25,9 +25,7 @@ on:
- 'config/scripts/daemon-endpoint-handover-smoke.mjs'
- 'config/scripts/windows-daemon-workspace-close-repro.mjs'
- 'config/scripts/verify-computer-native.mjs'
# Why: the native-smoke job boots the built terminal daemon under plain
# Node, so any change to the daemon bundle graph or the main build must
# re-run it (the v1.4.129-rc.1 daemon outage shipped with green CI).
# Re-smoke daemon bundle/build changes under plain Node; v1.4.129-rc.1 shipped a module-load outage despite green CI.
- 'electron.vite.config.ts'
- 'config/build-plugins/**'
- 'src/main/daemon/**'
@@ -90,10 +88,7 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
# Why: without --config, bare vitest ignores config/vitest.config.ts (there
# is no root config) and falls back to the 5s default timeout with no
# Windows worker cap, so the real csc.exe launcher-compile tests time out
# on hosted Windows. Use the shared config so this job matches pnpm test.
# Use shared Vitest config: bare invocation misses config/ and its timeout/Windows worker cap, breaking real csc.exe tests.
- run: >-
pnpm vitest run --config config/vitest.config.ts
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
@@ -130,19 +125,13 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm run build:electron-vite:parallel
# Why: boot the BUILT daemon-entry under plain Node the way production
# forks it. v1.4.129-rc.1 shipped a daemon that exited at module load
# (leaked electron require) while every other check passed; this fails
# the PR when the built daemon cannot start on ubuntu-22.04 / windows.
# Boot built daemon-entry under plain Node on Ubuntu/Windows, catching leaked electron requires like v1.4.129-rc.1.
- name: Daemon boot smoke
run: node config/scripts/daemon-boot-smoke.mjs
# Why: a daemon that lost its endpoint name used to delete the replacement's
# socket on exit, stranding a live daemon that hosts PTYs nothing can reach.
# Only two real processes racing the same endpoint reproduce it.
# Race real daemon processes on one endpoint; a loser must not unlink the replacement’s socket.
- name: Daemon endpoint handover smoke
run: node config/scripts/daemon-endpoint-handover-smoke.mjs
# Why: workspace removal overlaps graceful renderer teardown with the
# forced main-process sweep; exercise that exact pair against real ConPTY.
# Exercise workspace removal’s renderer teardown plus forced main sweep against real ConPTY.
- name: Windows daemon workspace-close repro
if: runner.os == 'Windows'
run: node config/scripts/windows-daemon-workspace-close-repro.mjs
+37 -188
View File
@@ -1,49 +1,6 @@
name: Daily macOS Dev Build
# Why: gives developers a signed macOS build of main once a day that the in-app
# updater can install directly, without waiting for an RC cut — less noise than
# hourly.
#
# Schedule is a single UTC cron (GH Actions has no timezone-aware schedules).
# 18:15 UTC is late morning Pacific year-round (10:15am PST / 11:15am PDT). Minute
# 15 avoids stacking with hourly-mac-build, which fires at minute 0 every hour.
#
# Deliberately narrow scope:
# - macOS only. Other platforms keep using RC/stable.
# - No tests or lint on the build job. After a live publish we fire-and-forget
# the full E2E workflow at the cut SHA (same detached dispatch as
# release-cut). A red suite must not fail or delay the daily.
# - PR CI and release-cut remain the gates that matter.
# - Signed AND notarized, exactly like a release. The notary round trip is the
# one slow step kept: macOS anchors a notarized app's TCC grants on identifier
# + team rather than on its cdhash, so those grants survive an update. Without
# a ticket every daily reads as a new client and silently loses file access
# under Documents/Desktop/Downloads.
#
# Artifacts publish to stablyai/orca-daily, never to stablyai/orca: the main
# repo's releases atom feed exposes only its 10 newest entries, so high-volume
# dev tags would evict every stable/RC entry and break updates for real users.
# Separate from orca-hourly so someone riding main's hourlies never sees the
# sparse daily series mixed into that list.
#
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use the
# same GitHub App as hourly (installed on orca-daily with Contents: Read and
# write). Provision the App secrets with
# `bash config/scripts/setup-hourly-release-token.sh`, then grant the App access
# to orca-daily with `bash config/scripts/setup-daily-release-repo.sh`:
# HOURLY_RELEASE_APP_ID the App's numeric id (shared with hourly/adhoc)
# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key
#
# Installation tokens live one hour, which is why this mints three times. Install
# and build need no token at all, and notarization can hold the publish step for
# tens of minutes; minting again once the build is done starts the clock at the
# first call that actually uses it rather than burning a third of it on
# `pnpm install`. The upload step's own retry budget (2x45m) can outlive that
# second token, so a third is minted after it for verify/publish/prune/cleanup —
# without it a stuck notary run would strand a draft that cleanup gets a 401 on.
# The release stays an unpublished draft until the manifest check passes, so the
# worst case is still an invisible draft — and the build-number query counts
# drafts, so it holds its number and the next run does not reuse it.
# Publish daily main builds to orca-daily, separate from hourly and stable/RC feeds. Sign/notarize for update-preserved macOS TCC grants; PR/release pipelines own blocking validation, with E2E detached after publish. Cron 18:15 UTC means 10:15 PST/11:15 PDT, offset from hourly. Use the shared hourly App with write access to orca-daily (setup-hourly-release-token.sh/setup-daily-release-repo.sh). Mint tokens again after build and upload because 2×45min retry can exceed their 1h life. Keep releases draft until manifests verify; drafts retain build numbers.
on:
schedule:
@@ -112,10 +69,7 @@ jobs:
head_sha: ${{ steps.freshness.outputs.head_sha }}
published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}
runs-on: blacksmith-6vcpu-macos-15
# Why 150: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
# the job is killed mid-retry and no cleanup step runs at all. A typical run
# is far shorter — this is the notary queue's tail, not its median.
# Allow 150min for install 3×10 + publish 2×45 + ~25 setup/build/verify, so outer timeout cannot strand cleanup mid-retry.
timeout-minutes: 150
env:
NODE_OPTIONS: --max-old-space-size=4096
@@ -126,10 +80,7 @@ jobs:
ref: ${{ needs.resolve-main.outputs.head_sha }}
# Version helpers only read HEAD; published versions come from git tags.
fetch-depth: 1
# Why: this job only reads stablyai/orca and never pushes; every write
# goes to the daily repo through a minted App token passed by env.
# Not persisting the checkout credential shrinks the blast radius if a
# build step is compromised (zizmor: artipacked).
# Read this repo without persisted credentials; write orca-daily through env-passed App token (zizmor: artipacked).
persist-credentials: false
- name: Mint daily repo token
@@ -141,8 +92,7 @@ jobs:
owner: stablyai
repositories: orca-daily
# Why: main is often idle. Rebuilding an unchanged commit burns a runner
# hour and adds a redundant tag to the retention window.
# Skip unchanged main to avoid duplicate builds/tags and runner cost.
- name: Check whether main moved since the last daily
id: freshness
shell: bash
@@ -158,9 +108,7 @@ jobs:
echo "Forced dispatch; building $head_sha."
exit 0
fi
# The previous daily records its source commit in the release body.
# Drafts are excluded: an unpublished leftover never shipped, so treating
# it as "the last build" would skip a build that never actually happened.
# Drafts never shipped; exclude them when comparing the previous daily’s source commit.
last_body="$(gh release list --repo "$DAILY_REPO" --limit 20 --json tagName,isDraft \
--jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)"
if [[ -z "$last_body" ]]; then
@@ -195,8 +143,7 @@ jobs:
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# Why: packaging bundles the pinned Node into the macOS terminal host. Keyed on the pin alone
# with no restore-keys, so a pin bump starts empty instead of carrying old archives forward.
# Cache bundled host Node by exact pin without restore keys.
- name: Locate the pinned Node runtime cache
if: steps.freshness.outputs.should_build == 'true'
shell: bash
@@ -220,8 +167,7 @@ jobs:
restore-keys: |
electron-builder-mac-
# Why both CPUs: the mac config packages x64 and arm64 from this arm64
# runner, so the install must carry both variants of the native optional deps.
# Install x64 and arm64 optional natives for both packaged macOS targets.
- name: Install dependencies
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
@@ -231,8 +177,7 @@ jobs:
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile --cpu=current,x64,arm64
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules.
# Build required out/mobile-web using mobile’s React Native/Expo install.
- uses: ./.github/actions/install-mobile-dependencies
if: steps.freshness.outputs.should_build == 'true'
@@ -257,21 +202,12 @@ jobs:
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Existing titles, which carry the build number this series continues
# from. The script picks the number, because it restarts per base version
# and only the script knows which base this build resolved to.
#
# Why drafts count here but not in the freshness check: that check asks
# "did this commit ship", where a draft is a no. This one asks "is the
# number free", where a stranded draft still holds one.
# The script continues numbering for its resolved base version; stranded drafts still reserve numbers,
# although freshness checks exclude them because they never shipped.
names="$(gh release list --repo "$DAILY_REPO" --limit 200 --json name \
--jq '.[].name // empty')"
# Why git tags, not GitHub releases: unpublishing a buggy cut deletes the
# GitHub release and leaves the tag. That dragged hourlies backwards so
# electron-updater stopped offering them; dailies would do the same. A
# separate token because GH_TOKEN above is the App's, scoped to the
# daily repo. Empty on failure — the script then falls back to
# package.json, which is stale but never wrong enough to fail a build.
# Tags survive release deletion, preventing updater version regression. Use a separate token because
# GH_TOKEN’s App grant is scoped to the channel repo; lookup failure falls back to package.json.
main_tags="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \
"repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \
--jq '.[].ref | sub("^refs/tags/"; "")' || true)"
@@ -285,11 +221,7 @@ jobs:
node config/scripts/daily-build-version.mjs \
>"$RUNNER_TEMP/daily-identity.txt"
grep -E '^(version|build_number)=' "$RUNNER_TEMP/daily-identity.txt"
# Why check rather than trust: the checkout above pins the resolved main commit, but a
# workflow_dispatch runs this file from whatever branch was dispatched. A
# branch that edits this step while main still has the old script yields
# an empty name and an untitled release — silent, and only visible once
# someone opens the releases page. Fail here instead.
# Dispatch YAML can be newer than the pinned source’s version script; reject missing output before publishing an untitled release.
if ! grep -q '^name=' "$RUNNER_TEMP/daily-identity.txt"; then
echo "::error::daily-build-version.mjs emitted no release name; this workflow and main's copy of the script are out of sync."
exit 1
@@ -301,9 +233,7 @@ jobs:
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
# Why: daily builds are not an official channel — telemetry's transport
# gate accepts only 'stable' or 'rc', so leaving this unset keeps them
# silent, which is correct for unvetted dev artifacts.
# Leave dev identity unset so official-only stable/rc telemetry stays silent.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
# After the app build so nothing that cleans out/ can drop it; electron-builder ships it.
@@ -314,11 +244,7 @@ jobs:
name: orcad-template
path: out/orcad-template
# Why a second mint: everything from here on writes to the daily repo, and
# the notary round trip inside the publish step can be tens of minutes. The
# token minted at the top has already spent install + build of its one hour
# on steps that never touched it; restarting the clock here gives the slow
# part the full budget.
# Refresh the one-hour token after read-only install/build, before long publish/notary work.
- name: Re-mint daily repo token for publish
id: app_token_publish
if: steps.freshness.outputs.should_build == 'true'
@@ -340,12 +266,9 @@ jobs:
SHA: ${{ steps.freshness.outputs.head_sha }}
run: |
set -euo pipefail
# Kept at 12 even though the title shows 7: the freshness check above
# parses this back out of the body to decide whether main has moved.
# Freshness parses the 12-character source SHA from the body even though titles show seven.
short_sha="${SHA:0:12}"
# Why a file rather than an inline string: the publish step re-asserts
# this same body, and the freshness check only works if the two agree
# exactly. One source, written once, read twice.
# Write one body file for creation and reassertion; freshness parsing requires exact agreement.
notes_file="$RUNNER_TEMP/daily-release-notes.md"
cat >"$notes_file" <<EOF
Automated daily macOS and Windows dev build from commit \`$short_sha\`.
@@ -362,16 +285,8 @@ jobs:
(SmartScreen will warn about an unknown publisher). Every later switch,
including back to Stable, works in-app from there.
EOF
# Why create it up front: electron-builder then uploads into a known tag
# rather than inferring one from package.json.
#
# Why --draft: everything between here and the manifest check is a window
# where the release exists but has no installable assets. A draft is
# absent from the releases list and from listReleaseBuilds, so a job that
# dies in that window — including a hard kill by the job timeout, which
# runs no cleanup step at all — leaves something invisible rather than a
# tag the picker offers and the download 404s on. It is flipped live only
# after the manifest is verified.
# Precreate a known tag as draft so builder cannot infer it from package.json.
# Keep incomplete assets invisible until manifest verification, even if timeout kills the job without cleanup.
gh release create "$TAG" \
--repo "$DAILY_REPO" \
--title "$NAME" \
@@ -384,17 +299,13 @@ jobs:
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
# Why 45 like the release pipeline: an attempt is pack + notarize +
# upload, and the notary queue is the unbounded part. Why 2 attempts and
# not 3: a missed daily costs a day, and a third attempt buys less than
# it costs in runner time once the notary is that stuck.
# Allow two 45min pack/notarize/upload attempts; a third costs more runner time than a missed daily.
timeout_minutes: 45
max_attempts: 2
retry_wait_seconds: 30
command: node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/pinned-node-downloads.mjs darwin-arm64 darwin-x64 && node config/scripts/ensure-native-runtime.mjs --runtime=electron && if [ -f config/scripts/build-mac-notarization-overlap.mjs ]; then ORCA_MAC_DAILY=1 node config/scripts/build-mac-notarization-overlap.mjs --publish always; else ORCA_MAC_DAILY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always; fi
env:
# Why: electron-builder's github publisher targets the repo named in the
# config; the token must therefore carry write access to orca-daily.
# Give the publisher App write access to its configured orca-daily target.
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
ORCA_DAILY_BUILD_VERSION: ${{ steps.daily.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.daily.outputs.commit }}
@@ -402,21 +313,12 @@ jobs:
ORCA_REQUIRE_ORCAD_TEMPLATE: '1'
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
# Why all three: electron-builder's notarize step authenticates to the
# Apple notary service with the app-specific password, not with the
# signing cert. Omitting them fails the build rather than skipping it,
# since `notarize` is now on for this path.
# Require Apple id/app-specific password/team for notary authentication, separate from signing cert.
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Why a third mint: the upload step above allows two 45-minute attempts, so
# it can outlive the one-hour token minted before it. Every remaining step
# writes to the daily repo, including the failure path that discards the
# draft — a 401 there is exactly the stranded draft nobody can clean up.
#
# Why always(): a failed or cancelled upload is the case that needs this
# most, since the cleanup step below runs only on that path.
# Always refresh after upload: 2×45min can outlive the earlier token, including failed/cancelled uploads needing draft cleanup.
- name: Re-mint daily repo token for verify and cleanup
id: app_token_final
if: always() && steps.freshness.outputs.should_build == 'true'
@@ -427,9 +329,7 @@ jobs:
owner: stablyai
repositories: orca-daily
# Why: the updater resolves a tag, then fetches latest-mac.yml from it. A
# release missing that manifest is a tag the picker offers and the download
# 404s on, so fail loudly instead of leaving a broken entry.
# Verify latest-mac.yml before exposing its release to prevent picker downloads returning 404.
- name: Verify update manifest published
if: steps.freshness.outputs.should_build == 'true'
shell: bash
@@ -441,10 +341,7 @@ jobs:
assets="$(gh release view "$TAG" --repo "$DAILY_REPO" --json assets --jq '.assets[].name')"
echo "Published assets:"
echo "$assets"
# Why exit 1 without deleting here: the release is still a draft, so it is
# already invisible to users, and the failure handler below owns cleanup.
# Deleting inline under `set -e` would also let the delete's exit code
# preempt this explicit failure.
# Drafts are already invisible; let failure cleanup delete them so set -e cannot mask this explicit failure.
for required in latest-mac.yml; do
if ! grep -qx "$required" <<<"$assets"; then
echo "::error::Daily draft $TAG is missing $required; the updater could not install it."
@@ -456,9 +353,7 @@ jobs:
exit 1
fi
# Why this is the last mutating step: publishing the draft is what makes the
# build visible to listReleaseBuilds. Doing it only after the manifest check
# means the picker can never offer a release whose assets are incomplete.
# Publish only after manifest validation so the picker cannot offer incomplete assets.
- name: Publish the verified release
id: publish_live
if: steps.freshness.outputs.should_build == 'true'
@@ -470,33 +365,19 @@ jobs:
NOTES_FILE: ${{ steps.release.outputs.notes_file }}
run: |
set -euo pipefail
# --title again: electron-builder resolves this draft by tag and may
# rewrite its title on upload. Re-asserting here means the name the
# picker reads is the one composed above, whatever it did in between.
# --notes-file for the same reason, and it matters more: the next run's
# freshness check parses the source commit back out of this body, so a
# body the publisher overwrote would rebuild an unchanged main daily.
# Builder may rewrite title/body on upload; reassert both so the picker name and next run’s source-SHA freshness check stay correct.
gh release edit "$TAG" --repo "$DAILY_REPO" --draft=false --prerelease \
--title "$NAME" --notes-file "$NOTES_FILE"
echo "Published $TAG as \"$NAME\""
# Why: a draft left behind by a failed publish is invisible to users but still
# holds its tag name, so the next run for the same minute would collide.
#
# Why it is gated on publish_live not having succeeded: a later failure (the
# prune step) must not delete a release that already went live and that users
# may already be installing. A job killed by the outer timeout runs no steps
# at all — which is exactly why the release stays a draft until verified.
# Why cancelled() too: a run stopped from the Actions UI is not a failure(),
# so without it a manual cancel mid-publish would strand the draft.
# Remove failed/cancelled stranded drafts, never releases already published by publish_live, including after prune failure. Outer timeout skips cleanup, so retain draft status until verified.
- name: Discard the draft release on failure
if: >-
(failure() || cancelled()) && steps.release.outputs.tag != '' &&
steps.publish_live.outcome != 'success'
shell: bash
env:
# Fall back to the publish token: if the re-mint itself is what failed,
# the older token is the only one left and may still have time on it.
# If token refresh fails, let cleanup try the older token while it may still be valid.
GH_TOKEN: ${{ steps.app_token_final.outputs.token || steps.app_token_publish.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
@@ -507,33 +388,18 @@ jobs:
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune old daily releases
# Only after a live publish: $TAG is then a non-draft we must not delete,
# and failed runs should not reshuffle retention around a draft that the
# failure path is about to discard.
# Prune only after publication; failed runs leave draft disposal to cleanup.
if: steps.publish_live.outcome == 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
# Protect the tag this run just shipped; at the retain cap, a bad sort
# can otherwise mark the newest release as stale and delete it.
# Never prune this run’s just-published tag at the retention cap.
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
# Why: --cleanup-tag so pruning does not leave orphan tags behind that
# keep showing up in tag lists with no release or assets attached.
# Drafts are excluded so retention counts shipped builds only; a stale
# draft is handled by the failure path, not by the retention window.
#
# Sort by publishedAt (not createdAt). Many non-draft dailies can share
# one createdAt (bulk import / re-create), so createdAt ranking is
# unstable. publishedAt is real recency; tagName (...YYYYMMDDHHMM) is
# the deterministic tie-break.
#
# Why force $TAG to the front before slicing: a hard retain-window seat
# for this run's release. Dropping $TAG from the list *before* the slice
# would permanently keep retain+1 releases; skipping it only in the
# delete loop would under-prune when the sort is still wrong. Partition
# keeps relative order of every other tag.
# Delete pruned tags too; exclude drafts owned by failure cleanup. Rank by publishedAt/tagName:
# createdAt ties (bulk import/recreation) can evict the just-published release. Partition $TAG to
# the front before slicing so it owns exactly one retained seat without under-pruning or keeping retain+1.
jq_filter='map(select(.isDraft | not)) | sort_by(.publishedAt // "", .tagName) | reverse'
if [[ -n "${TAG:-}" ]]; then
jq_filter+=" | (map(select(.tagName == \"${TAG//\"/\\\"}\")) + map(select(.tagName != \"${TAG//\"/\\\"}\")))"
@@ -557,13 +423,7 @@ jobs:
echo "::warning::Could not prune $tag"
done <<<"$stale"
# Why detached dispatch, not a reusable workflow in this graph: the full suite
# is currently red on main. Inlining it here would fail the daily after the
# signed build already published. Same pattern as release-cut's post-release-e2e.
#
# Why --ref main plus the SHA input: daily tags live on orca-daily, not this
# repo, so there is no cut tag for `gh workflow run --ref`. The workflow file
# comes from main; checkout uses the exact commit this daily built.
# Dispatch E2E separately so main’s red full suite cannot fail an already-published daily. Use main’s workflow plus exact built SHA because daily tags live in another repo.
post-daily-e2e:
needs: build-daily-mac
if: ${{ needs.build-daily-mac.outputs.published == 'true' && needs.build-daily-mac.outputs.head_sha != '' }}
@@ -588,21 +448,10 @@ jobs:
done
echo "::warning::Failed to dispatch post-daily E2E for $SHA after 3 attempts."
# Why this runs after the mac leg rather than beside it: the tag and version
# are computed inside that job, so until it has run nothing else can name the
# release to upload into. The cost is small enough not to matter — the Windows
# leg measures ~7.5 min (install 2m45, build 35s, NSIS package 3m) against a
# ~9.5 min mac run, which keeps a daily run far inside its interval.
#
# Why `./` rather than a pinned `@main`: `uses:` resolves against the ref this
# file itself came from, which for an ordinary dispatch is main. Someone who
# deliberately points the Actions "Use workflow from" picker at a branch
# already gets that branch's copy of this entire file, so this follows the same
# rule instead of inventing a second one.
# Wait for mac release identity before Windows uploads; local ./ workflow follows the dispatch ref consistently.
build-daily-win:
needs: build-daily-mac
# Only once the mac release is actually live: there is no release to upload
# into otherwise, and the Windows workflow refuses to create one.
# Start Windows publication only after the mac release is live; its workflow refuses to create a missing release.
if: needs.build-daily-mac.outputs.published == 'true'
permissions:
contents: read
+21 -92
View File
@@ -1,41 +1,9 @@
name: Dev Channel Windows Build
# Why its own file rather than steps inlined into hourly/daily/adhoc: one copy of
# the Windows leg instead of three, and it stays dispatchable on its own so a
# Windows artifact can be rebuilt for an existing tag without paying for the mac
# leg's packaging and notarization again.
#
# Adhoc can stage Windows alongside macOS, then publish only after macOS succeeds.
# Hourly, daily, and manual dispatches keep packaging and publishing together.
#
# Why unsigned: Windows release installers are signed by SignPath *after*
# packaging, and release-cut budgets 1h + 4h for those approval waits. That does
# not fit an hourly cadence and it does not fit "dispatch an adhoc build and go
# get coffee". So dev-channel Windows builds ship unsigned, which has one real
# consequence, handled in `src/shared/release-channel.ts`:
#
# electron-updater Authenticode-verifies every installer it downloads against
# the publisherName baked into the *installed* app's app-update.yml. Stable and
# RC carry 'SignPath Foundation', so they reject an unsigned dev installer and
# no future build can fix the copies already installed. Dev builds omit the
# name (config/electron-builder.config.cjs), so verification is skipped there.
#
# Net effect: the way *into* a dev channel on Windows is a one-time manual
# installer run. Every way out — to another dev build, or back to Stable — works
# through the in-app updater. The picker offers a download for exactly that jump.
#
# Mac workflows call this directly or stage artifacts for a later publish job.
# It is also dispatchable to rebuild Windows for an existing tag:
#
# gh workflow run dev-channel-win-build.yml --ref main \
# -f channel=adhoc -f tag=v1.4.178-adhoc.20260819010203 \
# -f ref=<sha> -f version=1.4.178-adhoc.20260819010203
# Share one independently dispatchable Windows dev build across hourly/daily/adhoc. Adhoc may stage beside Mac and publish after its success. Dev installers are unsigned because SignPath approval waits exceed this cadence. Installed stable/RC publisherName rejects them, requiring one manual entry install; dev builds omit publisherName so later in-app channel changes work. Manual rebuild: dispatch main with channel/tag/ref/version for an existing release.
on:
# Why the inputs are duplicated: workflow_call does not accept `type: choice`,
# and workflow_dispatch wants it so the Actions UI offers a menu instead of a
# free-text box. The channel allowlist below is what actually enforces the set,
# since a workflow_call caller can pass any string.
# Duplicate inputs because workflow_call lacks choice UI; validate its free-string channels with the allowlist.
workflow_call:
inputs:
channel:
@@ -104,18 +72,14 @@ permissions:
contents: read
concurrency:
# Keyed on the tag: re-dispatching the same tag must not race two uploads into
# one release, but two different channels (or two adhoc branches) are the
# ordinary case and must not wait on each other.
# Serialize per tag to prevent competing uploads while independent channels/branches proceed.
group: dev-channel-win-build-${{ inputs.tag }}
cancel-in-progress: false
jobs:
build-win:
if: github.repository == 'stablyai/orca'
# Why the same environment as the mac workflows: this needs the App token
# that can write to the dev-channel repos, and it should be reachable from
# exactly the same place those secrets already live.
# Use mac workflows’ same environment for cross-repo App secrets.
environment: adhoc-mac-build
# The fast image has VS Build Tools 2022; signed release provenance stays separate.
runs-on: ${{ inputs.fast_release && 'blacksmith-8vcpu-windows-2025' || 'windows-2022' }}
@@ -132,10 +96,7 @@ jobs:
VERSION: ${{ inputs.version }}
steps:
# Why vet before checkout: everything after this runs the checked-out code
# with a token that can write to a release repo. The mac leg already vetted
# the ref it resolved, but this workflow is dispatchable on its own, so it
# re-derives the same guarantee rather than trusting its caller.
# Re-vet source before checkout because standalone dispatch also exposes a release-write token.
- name: Vet the requested inputs
id: vetted
shell: bash
@@ -144,8 +105,7 @@ jobs:
REPO_URL: https://github.com/${{ github.repository }}
run: |
set -euo pipefail
# workflow_call takes channel as a free-text string, so the set is
# enforced here rather than by the input type.
# workflow_call inputs are free text; validate the channel set here.
case "$CHANNEL" in
hourly|daily|adhoc) ;;
*)
@@ -157,24 +117,18 @@ jobs:
echo "::error::ref must be a full 40-character commit SHA, got '$REQUESTED_SHA'. The dispatching workflow passes the commit it resolved."
exit 1
fi
# The tag must name the version being packaged, or the artifacts would
# land in a release describing a different build.
# Bind the tag to the packaged version so assets cannot describe a different build.
if [[ "$TAG" != "v$VERSION" ]]; then
echo "::error::tag '$TAG' does not match version '$VERSION'."
exit 1
fi
# And the version must carry the channel's own prerelease identifier,
# so an hourly artifact can never be uploaded into an adhoc release.
# Require the channel’s prerelease identifier so hourly assets cannot enter adhoc releases.
if [[ "$VERSION" != *"-$CHANNEL."* ]]; then
echo "::error::version '$VERSION' is not a $CHANNEL version."
exit 1
fi
# Reachability is the trust test: GitHub serves PR-only commits by SHA,
# so resolving the object is not proof a branch or tag of this repo
# reaches it. Bare + tree:0 keeps this to the commit graph; reftable
# because branches that differ only in casing cannot both be stored by
# the files backend on a case-insensitive runner disk, which fails the
# entire fetch rather than the one ref.
# Trust requires branch/tag reachability; GitHub serves PR-only SHAs too. Bare/tree:0 limits fetch to
# the commit graph; reftable avoids case-only refs failing the entire fetch on case-insensitive disks.
scratch="$RUNNER_TEMP/vet-requested-ref"
git init -q --bare --ref-format=reftable "$scratch"
git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
@@ -200,9 +154,7 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
# This job only reads stablyai/orca and never pushes; every write goes
# to the dev-channel repo through a minted App token passed by env
# (zizmor: artipacked).
# Read this repo without persisted credentials; writes use the dev-repo env App token (zizmor: artipacked).
persist-credentials: false
# Why: a relay-era ref (before #27215) needs the Windows relay addon this build no longer compiles.
@@ -214,11 +166,7 @@ jobs:
exit 1
fi
# Why a guard and not just a build: the workflow file comes from the
# dispatch ref, but the packaging config comes from the *built* commit. A
# branch cut before Windows dev builds landed has a config that ignores
# ORCA_WIN_*, which would resolve publish.repo to the main repo. Say that
# in one sentence here rather than failing deep inside electron-builder.
# Reject old packaging configs that ignore ORCA_WIN_* and would publish to the main repo.
- name: Resolve the dev-channel packaging identity
shell: bash
run: |
@@ -247,8 +195,7 @@ jobs:
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# Caches the Electron binary and electron-builder's tool downloads (nsis,
# winCodeSign). Same key shape as release-cut's Windows leg.
# Cache Electron/NSIS/winCodeSign tools with release-cut’s key shape.
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
@@ -259,11 +206,7 @@ jobs:
restore-keys: |
electron-builder-win-
# Why retried: pnpm install triggers electron's postinstall, which pulls the
# Electron binary from GitHub release assets, and that CDN returns transient
# 504s often enough to lose a build to it.
# Why host-only: this job packages only for its own runner OS and
# architecture, so the default host-scoped install is deliberate.
# Retry transient Electron CDN 504s during install; keep host-scoped natives for this runner’s only target.
- name: Install dependencies
uses: nick-fields/retry@v4
with:
@@ -272,13 +215,11 @@ jobs:
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules.
# Build required out/mobile-web from mobile’s React Native/Expo install.
- uses: ./.github/actions/install-mobile-dependencies
if: inputs.javascript_source_sha == ''
# Why the packaging check runs before the 20-minute build: it only needs
# node_modules, and a stale config should cost seconds rather than a build.
# Validate packaging config after install, before the expensive build.
- name: Verify dev-channel packaging identity
shell: bash
run: node config/scripts/verify-dev-channel-packaging.mjs --channel="$CHANNEL" --platform=win32
@@ -302,9 +243,7 @@ jobs:
env:
ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA: ${{ inputs.javascript_source_sha }}
ORCA_RELEASE_JAVASCRIPT_BUILD_IDENTITY: dev
# Why unset ORCA_BUILD_IDENTITY: telemetry's transport gate accepts only
# 'stable' or 'rc', so leaving it unset keeps dev builds silent — which
# is correct for unvetted artifacts. Same as the mac dev channels.
# Unset dev build identity so official stable/rc telemetry stays silent.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
# Inline so refs predating the wait can still use the trusted dispatch workflow.
@@ -340,8 +279,7 @@ jobs:
name: orcad-template
path: out/orcad-template
# Why the token is minted here and not at the top: installation tokens live
# one hour and nothing before this point writes anything.
# Mint the one-hour write token after read-only setup/build.
- name: Mint dev channel repo token
if: ${{ !inputs.stage_only }}
id: app_token
@@ -352,10 +290,7 @@ jobs:
owner: stablyai
repositories: orca-${{ inputs.channel }}
# Why: electron-builder's publisher creates a release when it cannot find
# the tag ("publish: always"). If the mac leg failed and discarded its draft
# while this was building, that would mint a fresh, untitled, Windows-only
# release. Check first and fail instead.
# Require existing release: publish:always would recreate a discarded mac draft as an untitled Windows-only release.
- name: Confirm the target release still exists
if: ${{ !inputs.stage_only }}
shell: bash
@@ -382,16 +317,10 @@ jobs:
ORCA_BUILD_COMMIT: ${{ inputs.ref }}
# beforePack and afterPack fail the package when the template is absent.
ORCA_REQUIRE_ORCAD_TEMPLATE: ${{ inputs.orcad_template && '1' || '' }}
# Why: electron-publish refuses to upload into a release published more
# than two hours ago (gitHubPublisher.getOrCreateRelease). The mac leg
# publishes the draft live as soon as *it* finishes, so a slow notary
# queue plus a slow Windows build can cross that line and silently drop
# every Windows asset. This is the documented escape hatch.
# Allow upload beyond publisher’s 2h release-age guard; slow mac notarization/Windows build can otherwise silently drop every asset.
EP_GH_IGNORE_TIME: 'true'
# Why: the updater resolves a tag, then fetches latest.yml from it. A
# release carrying the installer but not the manifest is one the picker
# offers and the update 404s on, so assert both.
# Require installer and latest.yml, preventing picker-visible releases with missing updater manifests.
- name: Verify Windows update manifest published
if: ${{ !inputs.stage_only }}
shell: bash
+3 -8
View File
@@ -105,8 +105,7 @@ jobs:
tag="$INPUT_TAG"
[[ "$EVENT_NAME" == "release" ]] && tag="$RELEASE_TAG"
# Match the stable desktop format used by release-policy.yml. This
# intentionally rejects mobile-* and all -rc.* tags.
# Use release-policy’s stable desktop tag shape, excluding mobile-* and RC tags.
stable_tag=false
[[ "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] && stable_tag=true
@@ -147,9 +146,7 @@ jobs:
fi
fi
# Resolve the tag to an immutable commit before handing it to the
# deployment job. This prevents a force-moved tag from changing the
# source between authorization and checkout.
# Pin the immutable commit before deployment so force-moving a tag cannot swap authorized source.
tag_ref_json=''
for attempt in 1 2 3; do
if tag_ref_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" 2>/dev/null)"; then
@@ -173,9 +170,7 @@ jobs:
fi
echo "commit_sha=$commit_sha" >> "$GITHUB_OUTPUT"
# Stable tags cut before the docs package was merged are valid Orca
# releases, but cannot produce a docs deployment. Skip them before
# requesting the protected production environment.
# Old stable tags may predate docs; skip before requesting the protected production environment.
docs_source_available=false
for attempt in 1 2 3; do
if gh api "repos/$GITHUB_REPOSITORY/contents/docs/site/package.json?ref=$commit_sha" >/dev/null 2>&1; then
+49 -22
View File
@@ -70,6 +70,8 @@ jobs:
if: inputs.test_files == '' || github.event_name != 'pull_request' || inputs.needs_build
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 15
outputs:
changed_e2e_shards: ${{ steps.changed-e2e-shards.outputs.shards }}
env:
ORCA_BACKGROUND_LAUNCH: '1'
@@ -79,6 +81,19 @@ jobs:
with:
ref: ${{ inputs.ref || github.ref }}
- name: Plan changed E2E allocation
id: changed-e2e-shards
env:
TEST_FILES_JSON: ${{ inputs.test_files }}
run: |
node --input-type=module <<'NODE'
import { appendFileSync, existsSync } from 'node:fs'
const modulePath = './config/scripts/ci-e2e-job-selection.mjs'
const selection = existsSync(modulePath) ? await import(modulePath) : {}
const shards = selection.generalE2eShards?.(process.env.TEST_FILES_JSON) ?? ['1/1']
appendFileSync(process.env.GITHUB_OUTPUT, `shards=${JSON.stringify(shards)}\n`)
NODE
- name: Install native build tools
env:
ORCA_E2E_APT_PACKAGES: build-essential python3
@@ -203,9 +218,7 @@ jobs:
fail-fast: false
matrix:
include:
# Fourteen scheduled runs averaged 24.6 minutes per shard; shards 4
# and 9 repeatedly hit the 30-minute cap. A 12-way trial still left
# one 30-minute shard, so 14 gives the suite enough failure headroom.
# Use 14 shards: scheduled average 24.6min and repeated 30min caps remained even in a 12-way trial.
- shard: '1/14'
shard_name: 1-of-14
- shard: '2/14'
@@ -260,14 +273,7 @@ jobs:
name: e2e-build-out${{ inputs.artifact_suffix }}
path: out/
# Why: the Electron suite is wall-clock constrained on OSS runners, but
# multiple Electron apps on one Xvfb VM contend on git/Chromium resources.
# Sharding keeps each VM at one Playwright worker while splitting the
# headless suite across separate runners.
# SKIP_BUILD makes Playwright globalSetup reuse the single build job's
# artifact instead of starting five concurrent electron-vite builds.
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
# launches but never creates a BrowserWindow.
# Shard one worker per VM to avoid Electron/git/Xvfb contention. Reuse one SKIP_BUILD artifact and forward app logs for pre-window failures.
- name: Balance E2E shard from timing evidence
env:
ORCA_BACKGROUND_LAUNCH: '1'
@@ -308,9 +314,7 @@ jobs:
if: matrix.shard == '1/14'
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/worktree-switch-first-paint.spec.ts --config tests/playwright.config.ts --project=electron-headful --workers=1
# Why: Playwright retains traces/screenshots only on failure. Uploading
# them as an artifact makes post-mortem debugging on CI possible without
# re-running locally.
# Upload failure traces/screenshots so CI failures are diagnosable without reproduction.
- name: Upload Playwright traces
if: failure() || cancelled()
uses: actions/upload-artifact@v7
@@ -321,11 +325,15 @@ jobs:
if-no-files-found: ignore
changed-e2e:
name: changed e2e specs
name: changed e2e specs (${{ matrix.shard }})
needs: [build]
if: inputs.test_files != '' && (github.event_name != 'pull_request' || inputs.run_changed_e2e)
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
shard: ${{ fromJSON(needs.build.outputs.changed_e2e_shards || '["1/1"]') }}
steps:
- name: Checkout
@@ -334,8 +342,7 @@ jobs:
ref: ${{ inputs.ref || github.ref }}
- name: Install native build and headless UI tools
# Why ripgrep: Quick Open's bounded host-side search requires rg instead of an
# unbounded inventory fallback; the paired fixture exercises that real boundary.
# Install rg for bounded Quick Open searches exercised by the paired fixture.
env: *e2e_tool_packages
run: *install_e2e_tools
@@ -353,6 +360,7 @@ jobs:
env:
TEST_FILES_JSON: ${{ inputs.test_files }}
LAYOUT_OWNER: ${{ inputs.layout_owner }}
SHARD: ${{ matrix.shard }}
run: |
if [ -f config/scripts/ci-e2e-job-selection.mjs ]; then
printf '%s\n' "$TEST_FILES_JSON" | node config/scripts/ci-e2e-job-selection.mjs > "$RUNNER_TEMP/general-e2e-specs"
@@ -385,8 +393,28 @@ jobs:
if grep -l '@headful' "${TEST_FILES[@]}" >/dev/null; then
E2E_PROJECT_ARGS+=(--project=electron-headful)
fi
E2E_SELECTION_ARGS=(--shard="$SHARD")
if [ -f config/scripts/ci-e2e-shard-plan.mjs ]; then
mkdir -p ci-shards-changed
pnpm exec playwright test "${TEST_FILES[@]}" --config tests/playwright.config.ts --project=electron-headless "${E2E_PROJECT_ARGS[@]}" --list --reporter=json > ci-shards-changed/discovery.json
export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)"
node config/scripts/ci-e2e-shard-plan.mjs ci-shards-changed/discovery.json "$SHARD" ci-shards-changed
pnpm exec playwright test "${TEST_FILES[@]}" --config tests/playwright.config.ts --project=electron-headless "${E2E_PROJECT_ARGS[@]}" --test-list=ci-shards-changed/selected.txt --list --reporter=json > ci-shards-changed/selected-discovery.json
node config/scripts/ci-e2e-shard-plan.mjs --verify ci-shards-changed/assignment.json ci-shards-changed/selected-discovery.json
E2E_SELECTION_ARGS=(--test-list=ci-shards-changed/selected.txt)
fi
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env "${E2E_ENV[@]}" \
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"
pnpm run test:e2e "${TEST_FILES[@]}" "${E2E_SELECTION_ARGS[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"
- name: Upload changed E2E shard assignment
if: always()
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: changed-e2e-shard-${{ strategy.job-index }}${{ inputs.artifact_suffix }}-attempt-${{ github.run_attempt }}
path: ci-shards-changed/
retention-days: 14
if-no-files-found: warn
- name: Summarize layout save divergence
if: always() && inputs.layout_owner == 'main'
@@ -407,7 +435,7 @@ jobs:
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: layout-divergence-changed${{ inputs.artifact_suffix }}-attempt-${{ github.run_attempt }}
name: layout-divergence-changed-${{ strategy.job-index }}${{ inputs.artifact_suffix }}-attempt-${{ github.run_attempt }}
path: layout-divergence/
retention-days: 14
if-no-files-found: warn
@@ -416,7 +444,7 @@ jobs:
if: failure() || cancelled()
uses: actions/upload-artifact@v7
with:
name: playwright-traces-changed${{ inputs.artifact_suffix }}
name: playwright-traces-changed-${{ strategy.job-index }}${{ inputs.artifact_suffix }}
path: test-results/
retention-days: 7
if-no-files-found: ignore
@@ -475,8 +503,7 @@ jobs:
slot="$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)"
pnpm build:orcad-prebuilds
pnpm build:orcad-prebuilds --require-slots "$slot"
# The headless oracle's orcad host must hold the same layout owner as the Electron host,
# and so must the template `orca serve` materializes its server from.
# The orcad host and its materialized template must use the Electron host’s layout owner.
if [ -n "$LAYOUT_OWNER" ]; then
export ORCA_LAYOUT_OWNER="$LAYOUT_OWNER"
pnpm build:orcad
+5 -29
View File
@@ -1,22 +1,6 @@
name: Homebrew Cask Bump
# Why: keep the stablyai/homebrew-orca tap in sync with GitHub Releases.
# Triggers:
# - workflow_call → release-cut.yml finished publishing a stable tag
# - workflow_dispatch → manual backfill for a specific tag
#
# The job computes the sha256 of orca-macos-arm64.dmg + orca-macos-x64.dmg
# from the release, templates the channel-specific cask in this repo with the
# new version and hashes, then PRs the result into stablyai/homebrew-orca.
# Stable tags update Casks/orca.rb; RC tags update Casks/orca@rc.rb.
#
# Why no `release.published` trigger: other products shipped from this repo
# (e.g. mobile under `mobile-v*`) also publish GitHub Releases. A blanket
# `release.published` trigger would fire this workflow for any of them and
# attempt to download `orca-macos-*.dmg` from a non-desktop release, at
# best 404ing and at worst (if the filter were weaker) rewriting the cask
# to a non-desktop version. The tap is only ever bumped by the desktop
# release pipeline calling this workflow directly.
# Update desktop stable/RC casks in stablyai/homebrew-orca with release DMG hashes through release-cut workflow_call or manual backfill. Avoid release.published because mobile/non-desktop releases lack those DMGs and must never rewrite casks.
on:
workflow_call:
@@ -132,10 +116,7 @@ jobs:
print(src)
PY
# Why: reuse the existing buf0-bot GitHub App instead of minting a new
# PAT. The app is installed org-wide, so it has access to
# stablyai/homebrew-orca automatically. GITHUB_TOKEN cannot push
# cross-repo; a short-lived installation token can.
# Use org-installed buf0-bot’s short-lived App token for cross-repo pushes; GITHUB_TOKEN is repo-scoped.
- name: Generate buf0-bot token
id: app-token
uses: actions/create-github-app-token@v3
@@ -166,8 +147,7 @@ jobs:
cp "$CASK_PATH" "tap/$CASK_PATH"
cd tap
# Why: commit author must match the buf0-bot app so commits are
# attributed to the bot in the tap's history / PR UI.
# Match the buf0-bot App’s commit author for tap history/PR attribution.
git config user.name "buf0-bot[bot]"
git config user.email "252831055+buf0-bot[bot]@users.noreply.github.com"
@@ -182,8 +162,7 @@ jobs:
git commit -m "$TITLE"
git push -u origin "$branch" --force
# Why: repeated backfills can race an existing PR branch, so edit
# the PR title if creation reports that one already exists.
# Backfills can race an existing branch; update its PR title when creation finds one.
gh pr create \
--title "$TITLE" \
--body "Automated bump of $CASK_PATH from stablyai/orca release $TAG." \
@@ -191,8 +170,5 @@ jobs:
--head "$branch" \
|| gh pr edit "$branch" --title "$TITLE"
# Why: squash-merge immediately rather than --auto. The tap has no
# required checks or branch protection, and `gh pr merge --auto`
# only activates when there's something to wait on — on a repo
# with no gates it silently no-ops, leaving the PR open forever.
# Squash immediately: this tap has no gates, so gh pr merge --auto silently no-ops.
gh pr merge "$branch" --squash --delete-branch
+35 -163
View File
@@ -1,39 +1,6 @@
name: Hourly macOS Dev Build
# Why: gives developers a signed macOS build of main every hour that the in-app
# updater can install directly, without waiting for an RC cut.
#
# Deliberately narrow scope:
# - macOS only. Other platforms keep using RC/stable.
# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI
# and release-cut remain the gates that matter.
# - Signed AND notarized, exactly like a release. The notary round trip is the
# one slow step kept: macOS anchors a notarized app's TCC grants on identifier
# + team rather than on its cdhash, so those grants survive an update. Without
# a ticket every hourly reads as a new client and silently loses file access
# under Documents/Desktop/Downloads — 24 times a day.
#
# Artifacts publish to stablyai/orca-hourly, never to stablyai/orca: the main
# repo's releases atom feed exposes only its 10 newest entries, so 24 hourly
# tags a day would evict every stable/RC entry and break updates for real users.
#
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use a
# GitHub App installed on orca-hourly with Contents: Read and write. Its private
# key does not expire, unlike a PAT — nothing here needs yearly rotation, and the
# credential belongs to the org rather than to whoever created it.
#
# Provision the two secrets with `bash config/scripts/setup-hourly-release-token.sh`:
# HOURLY_RELEASE_APP_ID the App's numeric id
# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key
#
# Installation tokens live one hour, so the build job mints twice. Install and
# build need no token at all, and notarization can hold the publish step for tens
# of minutes; minting again once the build is done starts the clock at the first
# call that actually uses it rather than burning a third of it on `pnpm install`.
# A pathological retry can still outrun the second token, but the release is an
# unpublished draft until the manifest check passes, so the damage is a stranded
# invisible draft — and the build-number query counts drafts, so it holds its
# number and the next run does not reuse it.
# Publish signed/notarized main builds to orca-hourly; notarization preserves TCC grants across updates. PR/release gates own validation. Separate repo avoids hourly tags crowding stable/RC Atom entries. Provision shared HOURLY_RELEASE_APP_ID/private key with setup-hourly-release-token.sh and App write access there. Refresh one-hour token after read-only build; a retry may still outlive it, leaving only an invisible draft whose build number remains reserved until manifests validate.
on:
schedule:
@@ -96,9 +63,7 @@ jobs:
echo "Forced dispatch; building $head_sha."
exit 0
fi
# The previous hourly records its source commit in the release body.
# Drafts are excluded: an unpublished leftover never shipped, so treating
# it as "the last build" would skip a build that never actually happened.
# Drafts never shipped; exclude them when comparing the previous hourly’s source commit.
last_body="$(gh release list --repo "$HOURLY_REPO" --limit 20 --json tagName,isDraft \
--jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)"
if [[ -z "$last_body" ]]; then
@@ -140,10 +105,7 @@ jobs:
head_sha: ${{ needs.preflight.outputs.head_sha }}
published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}
runs-on: blacksmith-6vcpu-macos-15
# Why 150: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
# the job is killed mid-retry and no cleanup step runs at all. A typical run
# is far shorter — this is the notary queue's tail, not its median.
# Allow 150min for install 3×10 + publish 2×45 + ~25 setup/build/verify; outer timeout otherwise interrupts cleanup.
timeout-minutes: 150
env:
NODE_OPTIONS: --max-old-space-size=4096
@@ -154,10 +116,7 @@ jobs:
ref: ${{ needs.preflight.outputs.head_sha }}
# Version helpers only read HEAD; published versions come from git tags.
fetch-depth: 1
# Why: this job only reads stablyai/orca and never pushes; every write
# goes to the hourly repo through a minted App token passed by env.
# Not persisting the checkout credential shrinks the blast radius if a
# build step is compromised (zizmor: artipacked).
# Read this repo without persisted credentials; write orca-hourly via env App token (zizmor: artipacked).
persist-credentials: false
- name: Mint hourly repo token
@@ -182,8 +141,7 @@ jobs:
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# Why: packaging bundles the pinned Node into the macOS terminal host. Keyed on the pin alone
# with no restore-keys, so a pin bump starts empty instead of carrying old archives forward.
# Cache bundled host Node by exact pin without restore keys.
- name: Locate the pinned Node runtime cache
shell: bash
run: echo "ORCA_NODE_RUNTIME_CACHE_DIR=$HOME/Library/Caches/orca-node-runtime" >>"$GITHUB_ENV"
@@ -204,8 +162,7 @@ jobs:
restore-keys: |
electron-builder-mac-
# Why both CPUs: the mac config packages x64 and arm64 from this arm64
# runner, so the install must carry both variants of the native optional deps.
# Install both x64/arm64 optional natives for both macOS packages.
- name: Install dependencies
uses: nick-fields/retry@v4
with:
@@ -214,12 +171,10 @@ jobs:
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile --cpu=current,x64,arm64
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules.
# Build required out/mobile-web from mobile’s React Native/Expo install.
- uses: ./.github/actions/install-mobile-dependencies
# Why: signing is what makes an hourly installable over an existing Orca, so
# a missing cert must fail here rather than after a 20-minute build.
# Require cert before expensive build; signing enables installation over existing Orca.
- name: Verify macOS signing environment
run: node config/scripts/verify-macos-release-env.mjs
env:
@@ -237,30 +192,17 @@ jobs:
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Existing titles, which carry the build number this series continues
# from. The script picks the number, because it restarts per base version
# and only the script knows which base this build resolved to.
#
# Why drafts count here but not in the freshness check: that check asks
# "did this commit ship", where a draft is a no. This one asks "is the
# number free", where a stranded draft still holds one.
# The script continues numbering for its resolved base version; stranded drafts still reserve numbers,
# although freshness checks exclude them because they never shipped.
names="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name \
--jq '.[].name // empty')"
# Why git tags, not GitHub releases: unpublishing a buggy cut deletes the
# GitHub release and leaves the tag. On 2026-09-14 we deleted v1.4.202's
# release for a bug; hourlies had already climbed to 1.4.203, then
# `gh release list` fell back to v1.4.201 and the next hourlies shipped
# as 1.4.202-hourly — which electron-updater will not install over
# 1.4.203-hourly or over the still-tagged 1.4.202. A separate token
# because GH_TOKEN above is the App's, scoped to the hourly repo. Empty
# on failure — the script then falls back to package.json, which is
# stale but never wrong enough to fail a build.
# Tags survive release deletion, preventing updater version regression. Use a separate token because
# GH_TOKEN’s App grant is scoped to the channel repo; lookup failure falls back to package.json.
# 2026-09-14: deleting v1.4.202 made release-list fallback ship 1.4.202-hourly below installed 1.4.203-hourly.
main_tags="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \
"repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \
--jq '.[].ref | sub("^refs/tags/"; "")' || true)"
# Already-shipped channel tags are a second floor: even if main's tag
# list is empty this run, a 1.4.203-hourly already out must not be
# followed by a 1.4.202-hourly.
# Already-shipped channel tags also floor versions: never follow installed 1.4.203-hourly with 1.4.202-hourly.
channel_tags="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName \
--jq '.[].tagName' || true)"
published="$main_tags"$'\n'"$channel_tags"
@@ -269,11 +211,7 @@ jobs:
node config/scripts/hourly-build-version.mjs \
>"$RUNNER_TEMP/hourly-identity.txt"
grep -E '^(version|build_number)=' "$RUNNER_TEMP/hourly-identity.txt"
# Why check rather than trust: the checkout above pins the resolved main commit, but a
# workflow_dispatch runs this file from whatever branch was dispatched. A
# branch that edits this step while main still has the old script yields
# an empty name and an untitled release — silent, and only visible once
# someone opens the releases page. Fail here instead.
# Dispatch YAML can be newer than the pinned source’s version script; reject missing output before publishing an untitled release.
if ! grep -q '^name=' "$RUNNER_TEMP/hourly-identity.txt"; then
echo "::error::hourly-build-version.mjs emitted no release name; this workflow and main's copy of the script are out of sync."
exit 1
@@ -284,9 +222,7 @@ jobs:
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
# Why: hourly builds are not an official channel — telemetry's transport
# gate accepts only 'stable' or 'rc', so leaving this unset keeps them
# silent, which is correct for unvetted dev artifacts.
# Unset dev identity so official-only stable/rc telemetry stays silent.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
# After the app build so nothing that cleans out/ can drop it; electron-builder ships it.
@@ -296,11 +232,7 @@ jobs:
name: orcad-template
path: out/orcad-template
# Why a second mint: everything from here on writes to the hourly repo, and
# the notary round trip inside the publish step can be tens of minutes. The
# token minted at the top has already spent install + build of its one hour
# on steps that never touched it; restarting the clock here gives the slow
# part the full budget.
# Refresh write token after build to give long notary/upload work a full hour.
- name: Re-mint hourly repo token for publish
id: app_token_publish
uses: actions/create-github-app-token@v2
@@ -320,19 +252,10 @@ jobs:
SHA: ${{ needs.preflight.outputs.head_sha }}
run: |
set -euo pipefail
# Kept at 12 even though the title shows 7: the freshness check above
# parses this back out of the body to decide whether main has moved.
# Freshness parses the 12-character source SHA from the body even though titles show seven.
short_sha="${SHA:0:12}"
# Why create it up front: electron-builder then uploads into a known tag
# rather than inferring one from package.json.
#
# Why --draft: everything between here and the manifest check is a window
# where the release exists but has no installable assets. A draft is
# absent from the releases list and from listReleaseBuilds, so a job that
# dies in that window — including a hard kill by the job timeout, which
# runs no cleanup step at all — leaves something invisible rather than a
# tag the picker offers and the download 404s on. It is flipped live only
# after the manifest is verified.
# Precreate a known tag as draft so builder cannot infer it from package.json.
# Keep incomplete assets invisible until manifest verification, even if timeout kills the job without cleanup.
gh release create "$TAG" \
--repo "$HOURLY_REPO" \
--title "$NAME" \
@@ -355,17 +278,13 @@ jobs:
- name: Publish hourly macOS artifacts
uses: nick-fields/retry@v4
with:
# Why 45 like the release pipeline: an attempt is pack + notarize +
# upload, and the notary queue is the unbounded part. Why 2 attempts and
# not 3: a missed hourly costs an hour, and the next cron picks the same
# commit up, so a third attempt buys less than it costs in runner time.
# Allow two 45min pack/notarize/upload attempts; next hourly picks the same commit rather than paying for a third.
timeout_minutes: 45
max_attempts: 2
retry_wait_seconds: 30
command: node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/pinned-node-downloads.mjs darwin-arm64 darwin-x64 && node config/scripts/ensure-native-runtime.mjs --runtime=electron && if [ -f config/scripts/build-mac-notarization-overlap.mjs ]; then ORCA_MAC_HOURLY=1 node config/scripts/build-mac-notarization-overlap.mjs --publish always; else ORCA_MAC_HOURLY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always; fi
env:
# Why: electron-builder's github publisher targets the repo named in the
# config; the token must therefore carry write access to orca-hourly.
# Give publisher token write access to configured orca-hourly repo.
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
ORCA_HOURLY_BUILD_VERSION: ${{ steps.hourly.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.hourly.outputs.commit }}
@@ -373,17 +292,12 @@ jobs:
ORCA_REQUIRE_ORCAD_TEMPLATE: '1'
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
# Why all three: electron-builder's notarize step authenticates to the
# Apple notary service with the app-specific password, not with the
# signing cert. Omitting them fails the build rather than skipping it,
# since `notarize` is now on for this path.
# Require Apple id/app-specific password/team for notarization, separate from signing cert.
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Why: the updater resolves a tag, then fetches latest-mac.yml from it. A
# release missing that manifest is a tag the picker offers and the download
# 404s on, so fail loudly instead of leaving a broken entry.
# Verify latest-mac.yml before exposing release; missing manifests cause picker download 404s.
- name: Verify update manifest published
shell: bash
env:
@@ -394,10 +308,7 @@ jobs:
assets="$(gh release view "$TAG" --repo "$HOURLY_REPO" --json assets --jq '.assets[].name')"
echo "Published assets:"
echo "$assets"
# Why exit 1 without deleting here: the release is still a draft, so it is
# already invisible to users, and the failure handler below owns cleanup.
# Deleting inline under `set -e` would also let the delete's exit code
# preempt this explicit failure.
# Drafts are already invisible; let failure cleanup delete them so set -e cannot mask this explicit failure.
for required in latest-mac.yml; do
if ! grep -qx "$required" <<<"$assets"; then
echo "::error::Hourly draft $TAG is missing $required; the updater could not install it."
@@ -409,9 +320,7 @@ jobs:
exit 1
fi
# Why this is the last mutating step: publishing the draft is what makes the
# build visible to listReleaseBuilds. Doing it only after the manifest check
# means the picker can never offer a release whose assets are incomplete.
# Publish only after manifests verify so picker never offers incomplete assets.
- name: Publish the verified release
id: publish_live
shell: bash
@@ -421,21 +330,11 @@ jobs:
NAME: ${{ steps.hourly.outputs.name }}
run: |
set -euo pipefail
# --title again: electron-builder resolves this draft by tag and may
# rewrite its title on upload. Re-asserting here means the name the
# picker reads is the one composed above, whatever it did in between.
# Reassert the picker title after electron-builder may rewrite the tag’s draft on upload.
gh release edit "$TAG" --repo "$HOURLY_REPO" --draft=false --prerelease --title "$NAME"
echo "Published $TAG as \"$NAME\""
# Why: a draft left behind by a failed publish is invisible to users but still
# holds its tag name, so the next run for the same minute would collide.
#
# Why it is gated on publish_live not having succeeded: a later failure (the
# prune step) must not delete a release that already went live and that users
# may already be installing. A job killed by the outer timeout runs no steps
# at all — which is exactly why the release stays a draft until verified.
# Why cancelled() too: a run stopped from the Actions UI is not a failure(),
# so without it a manual cancel mid-publish would strand the draft.
# Delete failed/cancelled drafts but never publish_live releases, including after prune failure; outer timeouts run no cleanup.
- name: Discard the draft release on failure
if: >-
(failure() || cancelled()) && steps.release.outputs.tag != '' &&
@@ -452,34 +351,18 @@ jobs:
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune old hourly releases
# Only after a live publish: $TAG is then a non-draft we must not delete,
# and failed runs should not reshuffle retention around a draft that the
# failure path is about to discard.
# Prune only after live publish; failed runs leave draft disposal to cleanup.
if: steps.publish_live.outcome == 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
# Protect the tag this run just shipped; at the retain cap, a bad sort
# can otherwise mark the newest release as stale and delete it.
# Exclude the just-published tag from retention pruning.
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
# Why: --cleanup-tag so pruning does not leave orphan tags behind that
# keep showing up in tag lists with no release or assets attached.
# Drafts are excluded so retention counts shipped builds only; a stale
# draft is handled by the failure path, not by the retention window.
#
# Sort by publishedAt (not createdAt). Many non-draft hourlies share one
# createdAt (bulk import / re-create), so createdAt ranking is unstable:
# reverse of a stable sort then drops the just-published tag past the
# retain window. publishedAt is real recency; tagName (...YYYYMMDDHHMM)
# is the deterministic tie-break.
#
# Why force $TAG to the front before slicing: a hard retain-window seat
# for this run's release. Dropping $TAG from the list *before* the slice
# would permanently keep retain+1 releases; skipping it only in the
# delete loop would under-prune when the sort is still wrong. Partition
# keeps relative order of every other tag.
# Delete pruned tags too; exclude drafts owned by failure cleanup. Rank by publishedAt/tagName:
# createdAt ties (bulk import/recreation) can evict the just-published release. Partition $TAG to
# the front before slicing so it owns exactly one retained seat without under-pruning or keeping retain+1.
jq_filter='map(select(.isDraft | not)) | sort_by(.publishedAt // "", .tagName) | reverse'
if [[ -n "${TAG:-}" ]]; then
jq_filter+=" | (map(select(.tagName == \"${TAG//\"/\\\"}\")) + map(select(.tagName != \"${TAG//\"/\\\"}\")))"
@@ -503,21 +386,10 @@ jobs:
echo "::warning::Could not prune $tag"
done <<<"$stale"
# Why this runs after the mac leg rather than beside it: the tag and version
# are computed inside that job, so until it has run nothing else can name the
# release to upload into. The cost is small enough not to matter — the Windows
# leg measures ~7.5 min (install 2m45, build 35s, NSIS package 3m) against a
# ~9.5 min mac run, which keeps a hourly run far inside its interval.
#
# Why `./` rather than a pinned `@main`: `uses:` resolves against the ref this
# file itself came from, which for an ordinary dispatch is main. Someone who
# deliberately points the Actions "Use workflow from" picker at a branch
# already gets that branch's copy of this entire file, so this follows the same
# rule instead of inventing a second one.
# Wait for mac tag/version before Windows upload; local ./ workflow consistently follows the dispatch ref.
build-hourly-win:
needs: build-hourly-mac
# Only once the mac release is actually live: there is no release to upload
# into otherwise, and the Windows workflow refuses to create one.
# Publish Windows only into the existing live mac release.
if: needs.build-hourly-mac.outputs.published == 'true'
permissions:
contents: read
+13 -34
View File
@@ -1,8 +1,6 @@
name: Mobile Android Release
# Why a separate workflow from iOS: iOS releases go through App Store review,
# which can take days. Decoupling the triggers lets an Android release ship
# immediately without waiting on iOS, and vice versa.
# Release Android independently so App Store review cannot block its shipping cadence.
on:
push:
tags:
@@ -23,10 +21,7 @@ jobs:
android-build:
runs-on: ubuntu-latest
# Why: the "Create GitHub Release" step below uses the default GITHUB_TOKEN
# to call `gh release create`, which requires contents:write. Without this,
# the job builds the APK fine but fails at release time with
# "HTTP 403: Resource not accessible by integration".
# Grant contents:write for gh release create; build success alone does not authorize publication.
permissions:
contents: write
@@ -75,10 +70,7 @@ jobs:
- name: Build Android release APK
run: cd android && ./gradlew assembleRelease
# Why: an install that fails with a missing certificate or a package-parse error is
# usually a download that died near the end, and the signature block sits in the last
# ~100 KB. Publishing the size and digest is what lets a reporter tell a truncated
# download apart from a bad build without anyone re-deriving them from the asset.
# Publish size/digest to distinguish truncated downloads (missing tail signature block) from bad APK builds.
- name: Checksum the APK
id: apk
run: |
@@ -93,10 +85,8 @@ jobs:
apk="${apks[0]}"
bytes="$(wc -c < "$apk" | tr -d ' ')"
sha256="$(sha256sum "$apk" | cut -d ' ' -f 1)"
# A sibling file in `sha256sum -c` format, so verifying a download is one command
# and no retyped digest. The asset globs below match *.apk and skip it. The ` *`
# marker is binary mode: Git Bash's sha256sum reads text mode as a licence to
# translate line endings while hashing, which would fail on a valid APK.
# Publish sha256sum -c sidecars, excluded by *.apk globs. Use binary “ *” mode:
# Git Bash text-mode newline translation can reject a valid APK.
printf '%s *%s\n' "$sha256" "$(basename "$apk")" > "$apk.sha256"
{
@@ -144,18 +134,13 @@ jobs:
'${{ steps.apk.outputs.bytes }}' '${{ steps.apk.outputs.sha256 }}'
}
# The section is always last, so dropping from its heading to EOF leaves the
# generated notes intact. \r* because a body round-tripped through the API has CRLFs.
# Verification is last; replace from heading to EOF, accepting API-roundtripped CRLFs.
drop_verification_section() {
sed '/^### Verify your download\r*$/,$d'
}
# Why: reuse the desktop release path's character-safe truncation so a multi-byte
# character cannot be split at the cap. The section's own length is reserved out of
# that cap, because appending after truncating would push a near-limit body past
# GitHub's API limit and fail the call — on the upload path, after --clobber has
# already replaced the assets. `wc -c` counts bytes, so the section's multi-byte
# characters over-reserve, which errs toward a shorter body.
# Truncate by characters like desktop releases and reserve the appended section inside GitHub’s cap,
# or --clobber could replace assets before notes fail. wc -c over-reserves multibyte section text safely.
write_notes_with_verification() {
NOTES_FILE="$notes_file" \
NOTES_RESERVE="$(verification_section | wc -c | tr -d ' ')" \
@@ -179,26 +164,21 @@ jobs:
--clobber \
android/app/build/outputs/apk/release/*.apk \
"${{ steps.apk.outputs.checksum }}"
# Why the body is rewritten too: --clobber replaced the APK, so a digest left
# over from the previous build now describes a file nobody can download, and a
# reader comparing against it would reject a good APK.
# --clobber replaced the APK; rewrite its digest so users cannot compare against obsolete bytes.
gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json body --jq .body \
| drop_verification_section > "$notes_file"
write_notes_with_verification
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --notes-file "$notes_file"
else
# Why: release tags live on side branches, so GitHub's automatic
# previous-tag detection reaches back several releases; that body
# already exceeds the 125000-character API limit and grows each
# release. Pin the comparison base and cap the size.
# Side-branch release tags make automatic previous-tag detection span multiple releases and exceed
# the 125000-character API cap; pin the comparison base and truncate.
previous_tag="$(
gh release list --repo "$GITHUB_REPOSITORY" --limit 200 --json tagName --jq '.[].tagName' \
| grep '^mobile-android-v' | grep -Fxv "$tag" | sort -V | tail -1 || true
)"
if [ -n "$previous_tag" ]; then
# Why: gh writes the JSON error body to stdout on an HTTP error, so a
# non-empty file is not proof of success — gate on exit status.
# gh writes HTTP error JSON to stdout too; require exit success, not merely a nonempty file.
if ! gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" -X POST \
-f tag_name="$tag" \
-f target_commitish="$GITHUB_SHA" \
@@ -222,8 +202,7 @@ jobs:
"${{ steps.apk.outputs.checksum }}"
fi
# Why a separate job: the release is already published here, so a failed bump only leaves
# the site's APK download on the previous release and must not fail the release run.
# Keep site bump advisory after publication; failure only leaves its download link on the prior APK.
bump-apk-download:
needs: android-build
if: needs.android-build.outputs.publish_release == 'true'
+4 -17
View File
@@ -1,8 +1,6 @@
name: Mobile iOS Release
# Why a separate workflow from Android: iOS releases go through App Store
# review, which can take days. Decoupling the triggers lets an Android release
# ship immediately without waiting on iOS, and vice versa.
# Release iOS independently so App Store review cannot block Android.
on:
push:
tags:
@@ -28,19 +26,13 @@ permissions:
jobs:
ios-build:
# GitHub-hosted macOS runner: required for Xcode. Expo SDK 55's
# expo-modules-core declares swift_version 6.0 and uses Swift 6 syntax
# (@MainActor isolation). Xcode 16.x (macos-15) can't even parse it
# ("unknown attribute 'MainActor'"), so we need Xcode 26.x → macos-26.
# Use macos-26/Xcode 26 for Expo SDK 55’s Swift 6 syntax/isolation; Xcode 16 runners cannot parse it.
runs-on: macos-26
# Archive + upload is ~30–40m when healthy; 90m leaves margin for setup.
timeout-minutes: 90
env:
# Why: this job and ios-distribute resolve gems ~25 minutes apart, so both
# must install the committed Gemfile.lock exactly. Frozen turns a lockfile
# drift into a setup failure instead of two different fastlane versions in
# one release.
# Freeze Gemfile.lock in both jobs; installs ~25min apart must not resolve different fastlane versions.
BUNDLE_FROZEN: 'true'
outputs:
@@ -106,12 +98,7 @@ jobs:
working-directory: mobile/ios
run: bundle exec pod install
# Why: `-allowProvisioningUpdates` + the App Store Connect API key can
# create/refresh provisioning profiles, but it cannot recreate the
# distribution certificate's PRIVATE KEY across runs. So we import a
# pre-exported distribution .p12 (created once via Apple Developer) into a
# throwaway keychain. The keychain is ephemeral to the runner and torn
# down with the VM; nothing secret is written to the repo.
# Import exported distribution .p12 into ephemeral runner keychain: provisioning updates/API key cannot recreate its private key.
- name: Import distribution certificate
env:
IOS_DIST_CERT_P12: ${{ secrets.IOS_DIST_CERT_P12 }}
+12 -39
View File
@@ -10,9 +10,7 @@ on:
- converted_to_draft
paths:
- 'mobile/**'
# Why all of src/shared: mobile imports hundreds of its modules, directly and through the RPC
# recordings and the host params check, so any shared edit can move a golden or break mobile's
# typecheck. Why the root lockfile: those modules resolve their packages from the root install.
# Route shared edits to mobile’s imports/RPC recordings/typecheck; root lockfile changes affect those modules’ root-resolved packages.
- 'src/shared/**'
# Mobile's Node RPC recording tools import this package.
- 'src/packages/process-host/**'
@@ -27,8 +25,7 @@ on:
- 'src/main/runtime/agent-session-*.ts'
- 'src/main/native-chat/agent-session-wire/**'
- 'src/main/runtime/structured-attention-original-delivery.test-fixture.ts'
# Why: this job holds the only checks that load the Fastfile, so edits to
# it or to the release workflow it guards must re-run them.
# Rerun the only Fastfile-loading checks for release-workflow/Fastfile edits.
- '.github/workflows/mobile.yml'
- 'config/mobile-ci.vitest.config.mjs'
- 'config/scripts/run-mobile-ci-tests.mjs'
@@ -57,9 +54,7 @@ on:
- '!src/main/**/*.test.[jt]s'
- '!src/main/**/*.test.[jt]sx'
- '!src/main/**/*.test.[cm][jt]s'
# Why main too: two pull requests can each pass against their own base and disagree once both
# land, and `verify` never runs on main. The same source paths as above, less the CI inputs only
# `verify` reads.
# Check matching source inputs on main too: independently passing PRs can conflict after merge, and verify is PR-only.
push:
branches:
- main
@@ -98,9 +93,7 @@ on:
- '!src/main/**/*.test.[cm][jt]s'
concurrency:
# Per commit on main, not per branch. GitHub cancels any PENDING run in a group when a new one
# queues, whatever `cancel-in-progress` says, so one shared main group drops the middle merge of
# three -- and a merge that breaks main there is exactly what `main-tests` checks for.
# Key main concurrency by commit: GitHub replaces pending runs even with cancel-in-progress:false, hiding middle-merge regressions.
group: mobile-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
@@ -111,15 +104,11 @@ jobs:
verify:
name: Mobile verify
if: github.event_name == 'pull_request' && github.event.pull_request.draft != true
# Why ARM: 209s of this job is Vitest and nothing here needs x86: no Android SDK, emulator, gradle,
# Hermes or Watchman, no docker, and no artifacts. The Gemfile.lock lists the generic `ruby`
# platform, so frozen bundler installs without an aarch64-linux entry.
# Use ARM for pure Vitest; no x86 native toolchain is needed and Gemfile.lock’s ruby platform supports frozen install.
runs-on: blacksmith-4vcpu-ubuntu-2404-arm
env:
# Why: an unfrozen bundler silently re-resolves when Gemfile.lock drifts
# from the Gemfile, which is how the release jobs could land on different
# fastlane versions in the first place. Fail here instead.
# Freeze bundler to reject Gemfile drift before release jobs resolve different fastlane versions.
BUNDLE_FROZEN: 'true'
ORCA_MOBILE_TYPECHECK_COMPILER: 'bun'
@@ -152,9 +141,7 @@ jobs:
echo 'should_run=true' >> "$GITHUB_OUTPUT"
fi
# bundler-cache installs mobile/Gemfile.lock, so this job is also what
# proves the pinned fastlane the release workflow depends on still
# resolves — before a release run finds out.
# Bundler-cache proves the release’s pinned fastlane still resolves before a release needs it.
- name: Setup Ruby and fastlane
id: ruby-tools
background: true
@@ -168,8 +155,7 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why here: pr.yml runs these import rules before mobile's install, when mobile's tsconfig
# (which extends expo's) can't load, so its import cycles were never seen. Zero tolerance.
# Check mobile import cycles after Expo deps install; pre-install pr.yml cannot load its tsconfig.
- name: Lint mobile imports with mobile dependencies installed
working-directory: .
run: node node_modules/oxlint/bin/oxlint --config config/oxlint-code-quality-native-plugins.json mobile --deny-warnings
@@ -189,12 +175,7 @@ jobs:
background: true
run: node scripts/check-tests-typecheck-ratchet.mjs
# This includes the bridged replay of the whole recording corpus, which used to be a second
# step of its own behind RPC_FOUNDATION_BRIDGE=1. A gate nobody can forget to set is the point:
# it fails when a divergence class grows, when a divergence lands in no class at all, or when
# one of the 103 goldens inside the C1 page closure changes the verdict it is pinned to. It is
# ~3 min of test time on its own, and Vitest runs it on a worker beside the rest of the suite,
# so folding it in costs a fraction of that in wall time and one step less to skip.
# Run bridged recording corpus unconditionally: reject growing/unclassified divergence and changed verdicts among 103 C1 goldens. Share worker time instead of a separate skippable env-gated step.
- name: Detect mobile test inputs
id: test-scope
shell: bash
@@ -221,9 +202,7 @@ jobs:
# All checks read the completed install; compilers need not hold up test workers.
- wait: [production-types, test-types, ruby-tools]
# Reports, never gates: the goldens are content-addressed JSON, so their raw diff is hashes.
# This decodes which recorded behaviour the pull request moves into the job summary, and runs
# after a red Test too, when a reviewer most wants it.
# Decode hash-addressed golden behavior changes for review, including after failed tests; advisory only.
- name: Summarize RPC recording changes
if: ${{ !cancelled() }}
continue-on-error: true
@@ -237,11 +216,7 @@ jobs:
if: steps.ruby-scope.outputs.should_run != 'false'
run: ruby fastlane/fastfile_testflight_arguments_test.rb
# Why: nothing else in CI loads the Fastfile, so a syntax error, a broken
# require, or an undefined constant only surfaces mid-release — the
# ios-distribute job failed every run for six days that way. `lanes` just
# loads and lists, so it needs no App Store Connect credentials and makes
# no network calls to Apple.
# Load/list Fastfile lanes without Apple credentials/network to catch syntax/require/constants before release; ios-distribute failed six days without this guard.
- name: Smoke-check the Fastfile
if: steps.ruby-scope.outputs.should_run != 'false'
env:
@@ -341,9 +316,7 @@ jobs:
background: true
run: node scripts/check-tests-typecheck-ratchet.mjs
# The whole suite, not only the recordings: the mutant and page-bridge suites read the goldens
# too. A red run here names each golden and the `rpc:record` command; the author of the merge
# that turned it red re-records in a follow-up.
# Run all golden consumers, including mutant/page-bridge suites; failures name the recording and rpc:record command.
- name: Setup Bun for tests
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
+1 -4
View File
@@ -22,10 +22,7 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 2
steps:
# Why no checkout: the Files API already has per-file additions/deletions.
# Why the default branch and never pull/<n>/head: this job holds a write-scoped
# GITHUB_TOKEN, so it may only execute reviewed code. A PR that edits these
# scripts takes effect once merged.
# Read file counts through API without checkout; execute only default-branch scripts because GITHUB_TOKEN has write scope. PR edits take effect after merge.
- name: Count test vs non-test LoC
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+40 -142
View File
@@ -18,12 +18,7 @@ permissions:
contents: read
jobs:
# Why: a README/docs-only PR used to start the full matrix (test shards,
# two package jobs, typecheck, git compat, xterm, shell contracts). Path
# filters on `on.pull_request` would drop the `verify` check entirely; this
# detector keeps verify as the required aggregate and skips the expensive jobs.
# Per-job outputs also skip git-compat/xterm/packaging/shell when those
# inputs are unchanged; empty diffs fail closed and run everything.
# Keep required verify present for docs-only PRs while routing expensive jobs by inputs; empty diffs fail closed to full checks.
code_paths:
if: github.event_name != 'pull_request' || github.event.pull_request.draft != true
name: detect changes and check repository guards
@@ -67,11 +62,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
# Why depth 50 and not 0: every diff below resolves to HEAD^1, so only the merge commit
# and a little slack are needed. Fetching all 8127 refs' commit graph cost ~20s here and
# is charged to the start of all 22 jobs, since they all need this one.
# Why blob:none stays: the sparse tree below is ~7 files, so there are no blobs to
# materialize and no promisor refetch. Measured 9.5s -> 1.6s against 20.7s today.
# Fetch 50 commits for merge HEAD^1 diffs instead of all 8127 refs (~20s); sparse ~7-file blob:none checkout avoids materialization/refetch (9.5s→1.6s).
fetch-depth: 50
filter: blob:none
sparse-checkout: |
@@ -128,12 +119,9 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
# Why HEAD^1 and not --merge-base: HEAD is the pull request merge commit, so its first
# parent is the base side already. Computing a merge base instead would require the
# payload base SHA to be in the graph, which is what forced a full-history checkout.
# PR merge HEAD’s first parent is the base; --merge-base would require the payload SHA/full history.
DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
# Why --no-renames: name-only rename detection can report only the destination.
# A code file moved under docs/ must still expose its code-side deletion.
# --no-renames retains code-side deletions when files move into docs.
CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR "$DIFF_BASE" HEAD)"
echo "Changed paths:"
printf '%s\n' "$CHANGED"
@@ -156,13 +144,11 @@ jobs:
if git diff --quiet --no-renames --diff-filter=ACDRTUXB "$DIFF_BASE" HEAD; then
E2E_SCOPE_ARGS=(--daemon-init-test-support)
fi
# Source routes are executable contracts so a test can prove exact
# authorities, exclusions, and sentinels without evaluating workflow shell.
# Executable routes let tests prove authorities/exclusions/sentinels without evaluating shell.
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs "${E2E_SCOPE_ARGS[@]}")"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
printf '%s\n' "$TEST_FILES_JSON" | node config/scripts/ci-e2e-job-selection.mjs --job-outputs >> "$GITHUB_OUTPUT"
# Why its own signal: the real-IME lane is a whole ibus session, not a spec, so it must
# trigger on IME source rather than on a spec name in some route's list.
# Real IME runs an entire IBus session; route it by IME source, independently of spec lists.
NATIVE_IME_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --native-ime-source)"
echo "native_ime_source_changed=$NATIVE_IME_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
WSL_CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR "$DIFF_BASE" HEAD)"
@@ -192,11 +178,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
# Why depth 50: the gates below diff against HEAD^1, so no merge base is computed and
# the payload base SHA need not be in the graph.
# Why no blob:none here, unlike code_paths: this job checks out all 30,226 files, and
# the filter then forces a second promisor fetch of nearly every blob. Measured 23s
# blobless against ~11s without it.
# Fetch 50 for merge HEAD^1, no merge-base graph needed. Full-tree checkout omits blob:none to avoid refetching ~30k blobs (23s versus ~11s).
fetch-depth: 50
persist-credentials: false
@@ -216,10 +198,7 @@ jobs:
continue-on-error: true
run: node -p '"executable=" + process.execPath' >> "$GITHUB_OUTPUT"
# Why two guarded installs: the mixed root+mobile store entry is 537 MB against
# 321 MB for root alone, and restoring it costs 8.6s against 4.6s. Most PRs skip the
# mobile install below, so they were paying 216 MB for packages they never link. The
# root-only key is also the one the hourly warmer reseeds. Only one of these runs.
# Choose one root/mobile install: root-only store is 321MB/4.6s versus mixed 537MB/8.6s, saving unused mobile downloads on most PRs.
- uses: ./.github/actions/install-node-dependencies
if: needs.code_paths.outputs.mobile_dependencies != 'true'
with:
@@ -325,9 +304,7 @@ jobs:
# Mobile installation changes import resolution for the native cycle check.
- wait: native-code-quality
# Why here: the changed-code gate lints mobile files too, and its type-aware pass
# resolves types from mobile/node_modules. Without the install every mobile type
# degrades to an `error` type — reported as phantom findings against the changed lines.
# Install mobile before type-aware changed-code lint or unresolved mobile types become phantom error-type findings.
- uses: ./.github/actions/install-mobile-dependencies
if: needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true'
@@ -419,9 +396,7 @@ jobs:
if: needs.code_paths.outputs.static_analysis == 'true'
run: node config/scripts/check-mobile-shared-dependency-versions.mjs
# Why: extraction writes sorted evidence to an isolated temporary path,
# so feature PRs need one normalized AST pass rather than a three-OS matrix.
# Use preflight's pinned Bun even when the optional local pin differs.
# Extract sorted evidence to isolated temp paths with one normalized platform-independent AST pass; use preflight’s pinned Bun.
- name: Verify localization extraction
if: '!cancelled()'
id: localization-extraction
@@ -461,9 +436,7 @@ jobs:
- uses: ./.github/actions/build-mobile-web-bundle
if: steps.orcad-smoke-scope.outputs.run == 'true'
# Why both: the ratchet proves nothing reachable from the runtime imports electron,
# which is a property of the import graph. This proves the Node artifact it enables
# actually boots, pairs, creates a worktree and round-trips a real PTY.
# Pair import-graph electron exclusion with built-Node boot/pair/worktree/PTY smoke; graph purity alone does not prove runtime operation.
- name: Boot orcad and round-trip a terminal
if: steps.orcad-smoke-scope.outputs.run == 'true'
env:
@@ -503,11 +476,7 @@ jobs:
- wait: [localization-catalogs, localization-extraction]
# Why: project-owned type declarations must live in .ts so tsc
# actually checks them. TypeScript's skipLibCheck: true (inherited
# from @electron-toolkit/tsconfig) silently widens unresolved names
# in .d.ts to `any`, which is how #1186 shipped a broken IPC signature
# past typecheck. See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts.
# Keep project declarations in .ts: skipLibCheck silently widened unresolved .d.ts names to any, hiding #1186 IPC breakage. See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts.
- name: Guard against project-owned .d.ts in preload/shared
if: needs.code_paths.outputs.static_analysis == 'true'
run: |
@@ -618,8 +587,7 @@ jobs:
"alpine/git:edge-2.38.1|2.38.1"
"alpine/git:v2.49.1|2.49.1"
)
# Why pull up front: a lane's first `docker run` otherwise pulls its image
# while the sibling lane is mid-test, and that stall is charged to the test.
# Pull before tests so a sibling’s timed lane cannot be stalled by the first image download.
for spec in "${specs[@]}"; do
docker pull --quiet "${spec%%|*}"
done
@@ -655,12 +623,7 @@ jobs:
done
exit "$status"
# Why this job: Orca's session index-heal depends on a Codex behavior — a
# `thread/read` of an unindexed rollout performs a read-repair that inserts the
# `threads` row. Every unit test drives a stub app-server and asserts only that the
# call did not error, so if Codex dropped the repair they would all stay green while
# the subsystem went inert. This runs the pinned real binary and fails when the
# repair stops happening. Pinned because the binary is the thing expected to drift.
# Run pinned real Codex: thread/read must repair an unindexed rollout’s threads row; stub-only units cannot detect upstream removal of this behavior.
codex_index_heal_contract:
name: Codex index-heal contract
needs: [code_paths, preflight]
@@ -692,9 +655,7 @@ jobs:
- name: Verify Codex index-heal contract
env:
# Why REQUIRED: without a binary the suite skips, and a job that skips
# reports success. This turns a failed or missing install into a red test
# instead of a green no-op.
# Require installed binary or skip would report green without exercising the contract.
ORCA_CODEX_CONTRACT_REQUIRED: '1'
ORCA_CODEX_CONTRACT_VERSION: ${{ env.CODEX_CLI_VERSION }}
run: |
@@ -719,9 +680,7 @@ jobs:
pnpm exec vitest run --config config/vitest.config.ts \
src/main/pty/codex-no-daemon-binary-contract.test.ts
# Why both pins and latest: Orca approves its managed-home hook entry with a hash
# Codex computes, so a release that changes it must redden here before users
# meet a review screen.
# Test pins plus latest because Codex’s managed-home approval hash can change before users encounter review prompts.
- name: Verify Codex hook file-entry contract (pinned)
env:
ORCA_CODEX_HOOK_CONTRACT_REQUIRED: '1'
@@ -768,9 +727,7 @@ jobs:
needs.code_paths.result == 'success' &&
needs.code_paths.outputs.xterm_patch_sync == 'true' &&
needs.preflight.result == 'success'
# Why ARM: the patch check rebuilds 4 packages x 2 builds and byte-compares against the
# checked-in bundles. Those were generated on Linux x64 and reproduce byte-for-byte on
# darwin-arm64, so the output is neither host-arch nor host-OS dependent.
# Use ARM for four-package pristine/patched rebuild byte comparison: output generated on Linux x64 reproduces on darwin-arm64.
runs-on: blacksmith-4vcpu-ubuntu-2404-arm
steps:
@@ -807,10 +764,7 @@ jobs:
# Bound stalled prerequisite downloads instead of waiting for GitHub's 6-hour default.
timeout-minutes: 15
env:
# Why: the suites below gate their live fish tests on the binary, which is
# right on a developer machine and wrong here — this job is a required check
# and its fish lane is the only end-to-end guard for #9993, so a skip would
# report green with nothing exercised. Turns those skips into failures.
# Require live fish instead of developer-friendly skips: this is the only full #9993 guard.
ORCA_REQUIRE_FISH: '1'
# Why: the runner image ships pwsh, and the codex wrapper's PowerShell lane must not skip.
ORCA_REQUIRE_PWSH: '1'
@@ -867,9 +821,7 @@ jobs:
- wait: shells
# Separate from the install so the failure names the contract, not an apt error.
# ORCA_REQUIRE_FISH re-checks this at test time; this step just fails in seconds
# instead of after a full dependency install.
# Fail fish prerequisites separately before dependency install; ORCA_REQUIRE_FISH checks again in tests.
- name: Require fish 4+
run: |
version="$(fish --version 2>/dev/null || true)"
@@ -926,17 +878,13 @@ jobs:
run_relay_integration: ${{ needs.code_paths.outputs.relay_integration != 'false' || needs.code_paths.outputs.orcad_browser == 'true' }}
browser_contract: ${{ needs.code_paths.outputs.orcad_browser == 'true' }}
# Why a sibling and not part of the test workflow: it is advisory, so it must not delay the
# gate. Inside unit-tests.yml a caller's `needs: test` waited for it, holding verify ~36s
# after the last shard. Deliberately absent from verify's needs for the same reason.
# Keep advisory evidence sibling to tests and outside verify.needs; reusable-workflow dependency otherwise added ~36s after last shard.
unit_selection_evidence:
needs: [test]
if: ${{ !cancelled() && (needs.test.result == 'success' || needs.test.result == 'failure') }}
uses: ./.github/workflows/unit-selection-evidence.yml
# Why its own job: it needs mobile/node_modules and a real browser, and the sharded `test`
# matrix would pay for both on every shard to run two files. It builds the same bundle the
# package job ships, and adds the render and census checks packaging does not run.
# Isolate two browser/mobile files to avoid every unit shard paying for their installs; test the same shipped bundle plus render/census.
mobile_web_app:
name: mobile web app bundle
needs: [code_paths]
@@ -958,9 +906,7 @@ jobs:
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# The drawer check runs on WebKit as well as Chrome, because the shell's iOS WebView is
# WebKit and the Chrome above cannot stand in for it. Downloaded rather than resolved from
# the runner: Ubuntu ships no WebKit build to point at.
# Test drawer with WebKit as iOS WebView uses it; runner Chrome cannot substitute.
- name: Install WebKit for the drawer check
id: webkit
background: true
@@ -976,10 +922,7 @@ jobs:
background: true
run: node config/scripts/run-mobile-web-app-checks.mjs --prepare-route-snapshot "$RUNNER_TEMP/mobile-routes.json"
# Why the runner's Google Chrome and not a downloaded chromium: same reason as the orcad
# browser job -- Ubuntu 24.04 only ships an AppArmor userns profile for the Chrome .deb.
# Why fail instead of skip: a silently skipped render check is the failure this job exists
# to prevent.
# Require runner Google Chrome: Ubuntu’s AppArmor userns profile covers its .deb, unlike downloaded Chromium; missing render checks must fail.
- name: Resolve Chrome for the render check
run: |
set -euo pipefail
@@ -997,16 +940,7 @@ jobs:
# Browser checks must observe both a finished install and the built bundle.
- wait: [webkit, mobile-routes]
# The bundling tests skip themselves where mobile dependencies are absent, which is how they
# stay green in the sharded `test` job. This is the job that installs them, so here a missing
# install has to fail rather than skip everything the job exists to run.
# Why a prefix and not a file list: the list this replaces had gone stale twice without
# anyone noticing, because a census whose closure block skips without the env flag below is
# green in the sharded `test` job whether or not it ever runs here. The prefix is the same
# one `pr-code-change-scope.mjs` fires this job on, so naming a test into the family is all
# it takes to have it run. Quoted because these are vitest filename filters, matched as
# substrings against the discovered files, and the shell must not touch them.
# Route censuses share fresh dependency lists for this invocation; scratch builds stay independent.
# Require mobile install and select census family by quoted filename prefix, matching routing; stale file lists/env-gated skips previously hid missing coverage.
- name: Builder, override census and render checks
env:
ORCA_MOBILE_WEB_APP_DEPS_REQUIRED: '1'
@@ -1026,15 +960,11 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404-arm
steps:
# Why fetch-depth 0: the harness extracts the newest release tag to skew
# current code against it. The default shallow clone has no tags, which is
# why this cannot ride along in the sharded `test` job.
# Fetch tags/full history for newest-release skew; shallow unit shards cannot supply it.
- name: Checkout
uses: actions/checkout@v6
with:
# Why blob:none: full history is needed for the merge-base diff, but historical
# file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
# few this job actually reads on demand.
# Use blob:none for merge-base history because blobs are ~89% of pack; fetch rare historical contents on demand.
fetch-depth: 0
filter: blob:none
persist-credentials: false
@@ -1057,8 +987,7 @@ jobs:
DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
pnpm run check:runtime-launcher-protocol-ratchet -- --base "$DIFF_BASE"
# Runs the whole directory so a new file there cannot be left out; an empty match
# exits 1 ("No test files found"), so this lane cannot pass while running zero tests.
# Run the entire directory; zero matches fail so new contracts cannot be omitted silently.
- name: Old/new client and server compatibility journeys
run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/
@@ -1141,9 +1070,7 @@ jobs:
ORCA_DAEMON_SHUTDOWN_FIXTURE_CACHE_IMAGE: ${{ steps.daemon-fixture-cache.outputs.image }}
run: node config/scripts/run-daemon-shutdown-descendants-docker.mjs
# Why --no-file-parallelism: every file here launches a full Electron stack twice, and each
# probe carries its own in-process deadline. Four at once on a 4-vCPU runner starve each other
# past those deadlines; serial, every probe owns the runner.
# Serialize dual-Electron lifecycle files: four concurrent probes on four vCPUs starve each other past their own deadlines.
- name: Test Linux Electron lifecycle boundary
env:
ORCA_BACKGROUND_LAUNCH: '1'
@@ -1243,9 +1170,7 @@ jobs:
background: true
run: pnpm run build:web-from-renderer
# Why here and not inside "Build package inputs": this job assembles packaging inputs step by
# step instead of calling build:release, and electron-builder's beforePack guard hard-fails
# without out/mobile-web. This is the real page, ~8 MB, not a bootstrap document.
# Build the real ~8MB mobile page because stepwise package assembly bypasses build:release and beforePack refuses its absence.
- name: Build mobile web bundle
run: pnpm run build:mobile-web
@@ -1341,8 +1266,7 @@ jobs:
restore-keys: |
electron-builder-win-
# Why persist-native-cache false: this job later rebuilds the same path for
# Electron. A post-job save would store the Electron ABI under the Node key.
# Do not save Node cache post-job: later Electron rebuild mutates the same paths to another ABI.
- uses: ./.github/actions/install-node-dependencies
id: deps
with:
@@ -1379,9 +1303,7 @@ jobs:
- name: Cache Windows CLI launcher
uses: actions/cache@v5
with:
# Why the cargo directories ride along: a hit on .build skips the build
# entirely, but a miss otherwise recompiles the resource crate from a
# cold registry.
# Cache cargo registry with .build so misses do not rebuild resource crates cold.
path: |
native/windows-cli-launcher/.build
native/windows-cli-launcher/target
@@ -1390,9 +1312,7 @@ jobs:
~/.cargo/registry
key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'native/windows-agent-hook/**', 'config/scripts/build-windows-cli-launcher.mjs', 'config/scripts/build-windows-agent-hook.mjs', 'config/scripts/windows-rust-executable-build.mjs', 'resources/build/icon.ico', 'package.json') }}
# Why an explicit step rather than trusting the runner image: the packaged
# CLI launcher is a native Rust binary, and a host without cargo fails deep
# inside electron-builder's native hook instead of here.
# Require cargo early; otherwise packaged Rust launcher fails deep inside builder native hooks.
- name: Ensure the Rust toolchain for the Windows CLI launcher
shell: pwsh
run: |
@@ -1402,8 +1322,7 @@ jobs:
}
cargo --version
# Why before the tests: the orca-agent-hook.exe suites then share one prebuilt binary rather
# than each starting its own build mid-run.
# Build agent-hook once before tests so suites share the binary instead of compiling concurrently.
- name: Build the Windows agent hook
run: node config/scripts/build-windows-agent-hook.mjs
@@ -1420,8 +1339,7 @@ jobs:
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts @files
# Why its own step: the CPU burners that make taskkill slow would starve
# every other test file sharing the runner.
# Isolate slow-taskkill CPU burners so they cannot starve unrelated test files.
- name: Test Windows tree kill under CPU load
env:
ORCA_WINDOWS_TREE_KILL_LOAD: '1'
@@ -1429,9 +1347,7 @@ jobs:
pnpm exec vitest run --config config/vitest.config.ts
src/shared/__tests__/process-host/windows-tree-kill-late-taskkill.win32.test.ts
# Why the :parallel variant: identical to build:release except the three
# electron-vite targets overlap instead of running back to back. The Linux package
# job already packages and smoke-tests an AppImage built that way.
# Use parallel Vite targets as already packaged/smoked on Linux; release behavior is otherwise identical.
- name: Build package inputs
env:
ORCA_CI_MAIN_SOURCEMAPS: '0'
@@ -1482,10 +1398,7 @@ jobs:
needs_build: ${{ needs.code_paths.outputs.e2e_needs_build != 'false' }}
managed_ssh_shards: ${{ needs.code_paths.outputs.e2e_ssh_shards || '["1/2","2/2"]' }}
# Why its own required job, apart from `e2e`: the rest of the suite is red on main, but the
# layout oracle is green there, and its known-on-main list absorbs today's findings. Only the two
# oracle specs run, through the same reusable workflow: the window spec in the changed-spec lane,
# the headless spec in the orcad lane on both serve hosts.
# Gate the green layout oracles separately from main’s red broader E2E suite: changed-spec window plus both orcad serve hosts.
layout_oracle:
name: layout oracle
needs: [code_paths, preflight]
@@ -1505,12 +1418,7 @@ jobs:
needs_build: true
artifact_suffix: -layout-oracle
# Why this is not in verify's needs: it is the first PR-gate run of a harness whose reliability
# is only known from nightly main runs (20/20 green, 2026-08-09..2026-08-29, p50 3m25s). It
# reports a red X on the PR without blocking, exactly like `e2e` above. Deliberately no
# continue-on-error: that renders the check green and hides the signal it exists to give. To
# make it blocking, add it to verify.needs, add TERMINAL_IME_NATIVE to the env below, and
# require `success || skipped` outside the strict loop — see the note on `e2e`.
# Keep native IME advisory while PR reliability is established (nightly 20/20, 2026-08-09..29, p50 3m25s); do not hide red via continue-on-error. To gate, add verify.needs, TERMINAL_IME_NATIVE env and separate success-or-skipped validation.
terminal_ime_native:
name: real IME
needs: code_paths
@@ -1530,9 +1438,7 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
# Why: pnpm's global virtual store links each package to only its declared dependencies, which
# is how developers install. Hoisting hides an undeclared one everywhere else, so install that way
# here with HOME and the store in an empty temp dir, where no stray node_modules can mask it.
# Install like developers’ global virtual store in empty HOME/store; hoisting or stray dependencies can mask undeclared imports.
dependency_isolation:
name: dependency isolation under the global virtual store
needs: [code_paths]
@@ -1546,8 +1452,7 @@ jobs:
ORCA_PNPM_FORCE_GLOBAL_VIRTUAL_STORE_IN_CI: '1'
steps:
# Job-wide (via GITHUB_ENV, since job env cannot read runner.temp) so every pnpm command,
# including pnpm run's own install check, keeps this layout.
# Persist layout via GITHUB_ENV (job env cannot read runner.temp) for every pnpm command/install check.
- name: Point HOME and the pnpm store at empty temp dirs
run: |
mkdir -p "$RUNNER_TEMP/empty-home"
@@ -1613,14 +1518,7 @@ jobs:
timeout-minutes: 5
steps:
# Why: e2e is deliberately absent from needs. The suite is currently red on
# main (every scheduled run), so gating merges on it would block any PR that
# touches tests/e2e/** — including the ones fixing the suite. Until it is
# green the job runs and reports for E2E-path PRs without blocking. To flip
# it on: add `e2e` to needs, add E2E to the env below, and require
# `"$E2E" = success || skipped` after the loop — skipped is the normal
# result for a path-filtered job and must keep passing, so it has to be
# checked outside the loop or it would excuse the jobs above.
# Keep E2E advisory while main’s scheduled suite is red, allowing fixes to merge. To gate, add e2e to needs/E2E env and check success-or-skipped outside the strict required-job loop.
- name: Require successful checks
env:
CODE_PATHS: ${{ needs.code_paths.result }}
+94 -399
View File
@@ -1,22 +1,6 @@
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, try to fast-forward its version;
# a pull-request-only rule leaves main unchanged and still pushes the tag.
# 7. Build and publish artifacts from the tag.
# Resolve ref/latest stable, compute next kind, reject stable version regression, commit/tag detached and build/publish from tag. Fast-forward main’s version only when source was its tip and policy permits; PR-only rules leave main unchanged. CI replaces local/scheduled release paths to avoid accidental working-tree releases.
on:
workflow_dispatch:
@@ -61,10 +45,7 @@ concurrency:
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.
# Limit version/tag mutations to canonical repo; fork schedules must not diverge their main and conflict upstream.
if: github.repository == 'stablyai/orca'
runs-on: ubuntu-latest
timeout-minutes: 15
@@ -81,13 +62,7 @@ jobs:
source_short_sha: ${{ steps.resolve.outputs.short_sha }}
ships_orcad_template: ${{ steps.orcad-template-support.outputs.ships }}
steps:
# Why inlined (not m-s-abeer/update-gha-summary-with-workflow-inputs):
# this job runs with contents:write and secret scope, so avoid executing
# any external (mutable @v1) action here. Surfaces every
# workflow_dispatch input as a table for audit; the resolved commit /
# branch / tag enrichment is written later in "Resolve ref SHA".
# Inputs are passed as JSON via env and parsed by jq as data — never
# interpolated into the shell — to avoid injection from dispatch values.
# Render dispatch audit summary locally rather than run mutable external action beside write token/secrets; parse env JSON with jq as data.
- name: Summarize workflow inputs
if: github.event_name == 'workflow_dispatch'
env:
@@ -98,8 +73,7 @@ jobs:
echo ""
echo "| Input | Value |"
echo "| --- | --- |"
# Values are data from env JSON; wrap in backticks for readability.
# Newlines collapsed so a multi-line input cannot break the table.
# Treat env JSON as data; collapse newlines so inputs cannot break the summary table.
jq -r '(. // {}) | to_entries[] | "| `\(.key)` | `\(.value | tostring | gsub("\n"; " "))` |"' <<<"$INPUTS_JSON"
} >> "$GITHUB_STEP_SUMMARY"
@@ -110,9 +84,7 @@ jobs:
fetch-depth: 0
# Version recovery needs complete history and tags, but not historical file contents.
filter: blob:none
# Why: version math recovers unpublished tags; checkout's default
# fetch-tags:false hides them, so a patch cut recreates vX.Y.Z and
# `git push` overwrites the existing tag.
# Fetch unpublished tags for version recovery; hidden tags can recreate/overwrite an existing version.
fetch-tags: true
# Why: a relay-era ref (before #27215) needs the Windows relay addon this build no longer compiles.
@@ -138,9 +110,7 @@ jobs:
- name: Resolve ref SHA
id: resolve
env:
# Why: keep the caller's ref as data (env) so we can label it in the
# summary without shell-interpolating a dispatch-controlled string
# into the script body.
# Keep caller ref in env data so summary labels cannot inject shell.
INPUT_REF: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }}
REPO: ${{ github.repository }}
SERVER_URL: ${{ github.server_url }}
@@ -153,9 +123,7 @@ jobs:
echo "sha=$sha" >>"$GITHUB_OUTPUT"
echo "short_sha=$short_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.
# Push the version bump to main only when cutting its exact tip; older/off-main refs publish tags only.
git fetch origin main --quiet
main_sha="$(git rev-parse origin/main)"
if [[ "$sha" == "$main_sha" ]]; then
@@ -164,11 +132,7 @@ jobs:
echo "push_main=false" >>"$GITHUB_OUTPUT"
fi
# Always surface the resolved commit in the job summary, plus any
# branches/tags that currently point at it (clickable). The raw
# `ref` input alone is ambiguous (branch vs tag vs SHA); for SHA
# inputs it also hides the human-readable names operators need
# when auditing RC cuts.
# Show the immutable commit and matching ref links: raw ref input is ambiguous and bare SHAs hide operator-readable names.
repo_url="${SERVER_URL}/${REPO}"
branches="$(
@@ -184,8 +148,7 @@ jobs:
|| true
)"
# Build comma-separated markdown links. Branch/tag names go in the
# URL path as-is (slashes must stay literal for GitHub tree URLs).
# Preserve literal slashes in branch/tag tree URLs.
linkify_names() {
local url_kind="$1"
local names="$2"
@@ -215,8 +178,7 @@ jobs:
branch_md="$(linkify_names branch "$branches")"
tag_md="$(linkify_names tag "$tags")"
# When no branch tip matches (historical SHA cuts), fall back to
# name-rev so the summary still shows something like `main~3`.
# Historical SHAs may lack a matching branch tip; name-rev supplies an auditable main~N label.
contains_md="_none_"
if [[ "$branch_md" == "_none_" ]]; then
approx="$(git name-rev --name-only --no-undefined --refs='refs/remotes/origin/*' "$sha" 2>/dev/null || true)"
@@ -274,8 +236,7 @@ jobs:
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.
# Delayed schedule delivery (e.g. 4:16 AM) must not cut the 3:00 AM slot.
if [[ "$pt_hour" == "03" || "$pt_hour" == "15" ]]; then
echo "allowed=true" >>"$GITHUB_OUTPUT"
echo "reason=target_hour:${pt_hour}:${pt_minute}" >>"$GITHUB_OUTPUT"
@@ -289,15 +250,13 @@ jobs:
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.
# Repeated target-hour schedules deduplicate via 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.
# Preserve dedupe for older scheduled workflows that predate the shared 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%% *}"
@@ -334,8 +293,7 @@ jobs:
- 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.
# Recover complete RC drafts before deciding whether manual dispatch needs another tag.
if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -343,9 +301,7 @@ jobs:
- 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.
# Stop after RC-only draft recovery; stable dispatch still cuts its requested version.
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 }}
@@ -355,35 +311,16 @@ jobs:
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.
# Use desktop v<digit> tag shape and numeric semver max, excluding RC/mobile tags.
# Builder flipped v1.3.22-rc.2’s prerelease flag on 2026-04-27; that flag cannot authorize stable math.
# 2026-05-04/run25304336767: mobile-v0.0.1 collapsed through NaN to 0.0.1 without the desktop prefix.
# 2026-06-04: release-list order put v1.4.42 before v1.4.44 and reopened 1.4.43 as 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 prerelease suffixes before numeric math: Number("1-rc") becomes NaN and silently collapsed
# to 1, producing v1.3.1-rc.4 on 2026-04-27 when latest_stable was misclassified.
strip_pre() { echo "${1%%-*}"; }
semver_gt() {
@@ -415,16 +352,8 @@ jobs:
}
require_valid_version_suffix() {
# 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.
# Why the numeric alternation rather than plain [0-9A-Za-z]+:
# semver forbids a leading zero on an all-digit identifier, and
# `npm version` silently renormalizes rc.4.01 to rc.4.1 while the
# tag step keeps the literal input — so the shipped package.json
# version and its own release tag would name different releases.
# Append rc.N.<identifier> so side builds sort above rc.N but below rc.N+1; clients match the suffix.
# Reject leading-zero numeric identifiers: npm normalizes rc.4.01 to rc.4.1 while tagging keeps the input.
if [[ ! "$1" =~ ^(0|[1-9][0-9]*|[0-9A-Za-z]*[A-Za-z][0-9A-Za-z]*)$ ]]; then
echo "::error::version_suffix (or the trailing .identifier in version) must be alphanumeric with no leading zero on an all-digit identifier, got: $1" >&2
exit 1
@@ -502,18 +431,10 @@ jobs:
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.
# Deleted releases can regress GitHub’s version list after their bump reached main; package.json floors the current ref.
if semver_gt "$package_stable" "$latest_stable"; then
# Skip floor-tag recovery when an explicit version is requested:
# recover_unpublished_tag can exit 0, which would recover the
# package-floor tag instead of cutting the requested version —
# defeating the very rollback scenario the override exists for.
# We still raise latest_stable to the floor below so the explicit
# version is gated against it; the collision recovery for the
# requested tag runs later.
# Explicit versions must skip floor-tag recovery (which can exit successfully with the wrong tag).
# Still enforce the package floor; recover collisions for the requested tag later.
if [[ "$KIND" != "rc" && -z "${EXPLICIT_VERSION:-}" ]]; then
package_tag="v$package_stable"
if git rev-parse "$package_tag" >/dev/null 2>&1; then
@@ -526,84 +447,44 @@ jobs:
fi
fi
# Explicit version override (manual dispatch only).
#
# Why: kind-based math derives the next number from the latest
# *published* stable. When a shipped stable is deleted (e.g. a
# rolled-back 1.4.154), the release list regresses to the prior
# stable, so a kind cut recomputes a number at or below the nuked one
# and strands every client that already installed the deleted build.
# The package.json floor above only recovers this when the deleted
# version's bump commit is on the ref being cut, which a hotfix cut
# from an older RC ref does not carry. An explicit version lets a
# human assert the exact target (e.g. leapfrog to 1.4.155); the
# updater-safety gate and tag-collision recovery below still apply.
# Manual explicit versions let hotfixes from older refs leapfrog deleted shipped versions (e.g. 1.4.154 → 1.4.155)
# when published-release math regresses and the ref lacks the newer package floor. Keep updater/tag-collision gates.
new=""
if [[ -n "${EXPLICIT_VERSION:-}" ]]; then
explicit="${EXPLICIT_VERSION#v}"
# Why the optional trailing identifier: it lets an operator name a
# suffixed side-branch RC (X.Y.Z-rc.N.perf) directly, the same shape
# the rc path cuts. Note this only ever admits one *above* the
# series head — the gate below refuses a suffixed rc at or below it
# just like a bare one, so this is a second spelling of
# `version=X.Y.Z-rc.N` + `version_suffix`, not a way back into a
# series that already shipped.
# Why rc.(0|[1-9][0-9]{0,8}): the `-le` below compares with bash's
# machine-width integers, so both ends of that range fall *open* on
# exactly the RCs this gate must catch. A leading zero (rc.08) is an
# invalid octal literal, and the failed test makes the `if` false.
# Past INTMAX the literal wraps two's-complement, so whether it
# reads as above or below the published rc depends on the value:
# rc.99999999999999999999 wraps to 7766279631452241919 and sails
# through. The cut then lands a tag that pins highest_rc_for_base
# at 1e20 forever, and every later cut wraps to a *lower* rc that
# sorts below it, so the fleet never updates again. Nine digits is
# far above any real series and exact in bash math either way.
# Accept one optional side-branch RC identifier, still strictly above the series head; it grants no rollback exception.
# Bound rc ordinals to nine digits without leading zeros: Bash -le misreads rc.08 as octal and wraps INTMAX,
# letting rc.99999999999999999999 pass as 7766279631452241919 and pin highest_rc_for_base at 1e20 forever.
if [[ ! "$explicit" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.(0|[1-9][0-9]{0,8})(\.[0-9A-Za-z]+)?)?$ ]]; then
echo "::error::version must be X.Y.Z, X.Y.Z-rc.N, or X.Y.Z-rc.N.suffix, got: $EXPLICIT_VERSION" >&2
exit 1
fi
# Why route the embedded identifier through the same validator the
# kind path uses: the regex above only checks shape, and rc.4.01
# is a shape-valid identifier that is not valid semver.
# Reuse identifier validation: rc.4.01 matches shape but is invalid semver.
if [[ "$explicit" == *-rc.*.* ]]; then
require_valid_version_suffix "${explicit##*.}"
fi
# Same updater-safety gate the kind path enforces: stable line must
# strictly increase over the latest published stable (prerelease
# identifiers ignored for the comparison).
# Explicit stable lines must strictly exceed published stable, ignoring prerelease identifiers.
if ! semver_gt "$explicit" "$latest_stable"; then
echo "::error::Refusing explicit version $explicit: not greater than latest stable $latest_stable." >&2
exit 1
fi
# Why a second gate for prereleases: semver_gt compares through
# strip_pre(), so the stable-line check reads 1.4.156-rc.0 as
# 1.4.156 and waves it past a 1.4.155 stable even when rc.0..rc.3
# already shipped — republishing an RC *below* what clients run,
# the same regression class as the rc.4 cut that orphaned live
# daemons. Anchor on the same rc history the kind path uses so the
# override can only ever advance the series it targets.
# Stable-line comparison alone admits rc.0 after rc.3 shipped. Require the override to advance its
# own series too, avoiding the rc.4 regression that orphaned live daemons.
if [[ "$explicit" == *-rc.* ]]; then
explicit_base="${explicit%%-*}"
explicit_rc="${explicit#*-rc.}"
explicit_rc="${explicit_rc%%.*}"
highest_explicit_rc="$(highest_rc_for_base "$explicit_base")"
if [[ -n "$highest_explicit_rc" && "$explicit_rc" -le "$highest_explicit_rc" ]]; then
# Why the remedy is spelled this narrowly: kind=rc derives its
# base from bump(latest_stable, patch), so it can only resume a
# series on that base. A minor/major series (1.5.0-rc.N) exists
# only because this override created it, and pointing an
# operator at kind=rc there would cut an unrelated release.
# kind=rc resumes only latest stable’s next patch; minor/major RC series require explicit overrides,
# or the suggested remedy would cut an unrelated release.
echo "::error::Refusing explicit version $explicit: rc.$explicit_rc is not above rc.$highest_explicit_rc, the highest already cut for $explicit_base. Request rc.$((highest_explicit_rc + 1)) or higher. If you are resuming an unpublished tag and $explicit_base is the next patch after latest stable $latest_stable, dispatch kind=rc instead, which recovers that tag when it was cut from the ref you dispatch; otherwise cut rc.$((highest_explicit_rc + 1)) and leave the unpublished tag alone." >&2
exit 1
fi
fi
new="$explicit"
# Why here too: the suffix append below lives in the kind path the
# override skips, so an operator passing both inputs used to get
# their suffix silently dropped. Only a bare rc can take one — a
# stable X.Y.Z.perf is not valid semver, and re-suffixing an
# already-suffixed rc would produce rc.N.perf.perf.
# The override bypasses kind-path suffixing: append here too, only to bare RCs, avoiding invalid
# stable X.Y.Z.perf or repeated rc.N.perf.perf suffixes.
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
# Same bounded rc pattern as the shape check above, so the two
# cannot drift apart under a later edit.
@@ -620,23 +501,15 @@ jobs:
if [[ -z "$new" ]]; then
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.
# RCs target published stable + next patch. Highest-tag continuation reopened shipped series
# (v1.3.21-rc.7 after v1.3.21 stable); cut minor/major stable first to start those RC bases.
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.
# Resume highest draft/missing RC only when 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
@@ -655,10 +528,7 @@ jobs:
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.
# If an old orphan stable tag cannot be recovered for this ref, advance rather than wedge future patch cuts.
for _ in {1..100}; do
candidate_tag="v$new"
if ! git rev-parse "$candidate_tag" >/dev/null 2>&1; then
@@ -689,29 +559,9 @@ jobs:
esac
fi
# 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.
# Recover existing draft/absent releases by emitting the pushed tag and rerunning builds without bump/commit/push.
# 2026-05-01/run25237882049: cancellation after pushing v1.3.26 wedged every patch cut on that orphan tag
# until manual release dispatch. A tag plus published release is a real collision requiring operator 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
@@ -731,12 +581,9 @@ jobs:
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).
# Control commit message/tag ourselves via npm version --no-git-tag-version, avoiding npm’s prefix/lifecycle assumptions.
npm version "$VERSION" --no-git-tag-version --allow-same-version
# Why: freshness compares installs against the bundled manifest, so a tag must
# never ship skill bytes it doesn't describe. Node built-ins only; no install.
# Tags must describe their bundled skill bytes; verify freshness with Node built-ins before install.
if ! node config/scripts/generate-skill-bundle-manifest.mjs; then
echo "::error::Refusing to tag v$VERSION: the committed skill manifest does not match skills/. Land a regeneration on main, then re-run the cut." >&2
exit 1
@@ -747,23 +594,14 @@ jobs:
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.
# A failed cut may already have bumped main; re-cut still needs a fresh taggable commit.
git commit --allow-empty -m "$commit_message"
else
git commit -m "$commit_message"
fi
# Why: a lint that greps this file cannot see a path built from an env
# var, a composite action, or concatenation, and `git commit` has forms
# (-a, -i, --only, a pathspec) that commit the working tree rather than
# the index. Assert what the commit actually carries, so the tag can
# only ever ship the version bump, no matter which step staged what or
# how the commit was spelled.
# -F because the allowlist is literal: unanchored, `.` would match any
# character and quietly admit a path like `packageXjson`.
# -m --first-parent: plain diff-tree prints NOTHING for a merge commit,
# which would make this guard pass silently rather than fail closed.
# Validate the committed tree, not staged intent: git commit -a/-i/--only/pathspec can bypass the index.
# Literal -F matching cannot accidentally admit packageXjson; -m --first-parent makes merge commits visible
# to diff-tree so the allowlist cannot silently pass an empty merge diff.
committed="$(git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD |
grep -vxF -e 'package.json' || true)"
if [[ -n "$committed" ]]; then
@@ -924,10 +762,7 @@ jobs:
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
# Why: this install runs lifecycle scripts, so node-gyp rebuilds
# native/windows-registry and fetches that Node version's headers from
# nodejs.org. One `read ECONNRESET` there failed this blocking gate and the
# whole cut. Retry like the release build's install below.
# Retry node-gyp header fetch ECONNRESET so transient install failures cannot fail the whole release.
- name: Install dependencies
uses: nick-fields/retry@v4
with:
@@ -939,10 +774,7 @@ jobs:
- name: Build Electron app for platform golden
run: npx electron-vite build --mode e2e
# Why: this job is defined on the dispatch ref (usually main) but checks
# out the release tag. Cherry-pick / hotfix tags can predate a golden
# script that main already calls; --if-present keeps those cuts green
# instead of failing with ERR_PNPM_NO_SCRIPT.
# Use --if-present for golden scripts absent from older hotfix/cut tags, despite newer dispatch YAML.
- name: Run terminal rendering golden on Linux
if: runner.os == 'Linux'
run: |
@@ -1186,9 +1018,7 @@ jobs:
retention-days: 7
if-no-files-found: ignore
# Design D2: every desktop build ships the orcad template (server JS plus every target's
# addons), merged from the node-server lanes that qualified each slot at this tag. It needs no
# signing quota, so it runs beside the release gates instead of behind them.
# Build every target’s qualified orcad slot at the tag beside release gates; unsigned template consumes no signing quota.
orcad-template:
needs: cut
if: needs.cut.outputs.should_release == 'true' && needs.cut.outputs.ships_orcad_template == 'true'
@@ -1200,9 +1030,7 @@ jobs:
ref: refs/tags/${{ needs.cut.outputs.tag }}
fast_runners: false
# Why: artifact jobs submit Windows binaries to SignPath. Keep every
# quota-consuming build behind all blocking release gates so a late test
# failure cannot create signing requests that can never be published.
# Gate SignPath artifact requests behind blocking tests to avoid approvals for unpublishable builds.
release-preflight:
needs:
- cut
@@ -1285,9 +1113,7 @@ jobs:
~/.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.
# Fit 1h inner+4h installer SignPath waits and build under hosted runner’s 6h cap.
timeout-minutes: 360
permissions:
@@ -1310,16 +1136,9 @@ jobs:
echo "Windows artifact/signing steps are disabled on reruns (attempt $GITHUB_RUN_ATTEMPT)."
echo "Existing signed release assets must be reused; dispatch a fresh release only when a rebuild is required." >> "$GITHUB_STEP_SUMMARY"
# Why: `uses: ./…` resolves from the checked-out tag, not from the workflow
# ref, so cutting from an older/off-main ref whose tree predates a composite
# action would fail the step with "Can't find 'action.yml'". Restore the
# actions directory from the commit this workflow file itself came from.
# Not Windows-only: every platform now consumes install-mobile-dependencies, so
# any of them can be the one whose cut ref predates the action.
# Restore composite actions from workflow commit: uses:./ resolves checked-out tag, which may predate mobile/dependency actions.
- name: Restore draft-publish scripts from the workflow ref
# Why: this job checks out the release tag, so a cut from an older SHA
# still has electron-builder releaseType:release and no re-draft helper.
# The workflow YAML is from main; restore the scripts it invokes.
# Restore current workflow helper scripts when old cut tags still publish live and lack re-draft support.
shell: bash
env:
WORKFLOW_SHA: ${{ github.workflow_sha }}
@@ -1370,23 +1189,14 @@ jobs:
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# 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.
# Override non-executable pnpm node-gyp only on Linux (run 25081763129); runner.os keeps future Linux entries covered.
- 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.
# Cache Electron and builder downloads (winCodeSign/NSIS/Squirrel/AppImage), saving ~30–90s per job.
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
@@ -1405,12 +1215,7 @@ jobs:
# The release cache above restores the same tools, so this only needs to save on a miss.
lookup-only: true
# 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.
# Why host-only: this job packages only for its own runner OS and
# architecture, so the default host-scoped install is deliberate.
# Retry transient Electron CDN 504s during install; keep host-scoped native dependencies for this platform’s package.
- name: Install dependencies
uses: nick-fields/retry@v4
with:
@@ -1419,28 +1224,15 @@ jobs:
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules.
# Build required out/mobile-web from mobile’s React Native/Expo dependencies.
- uses: ./.github/actions/install-mobile-dependencies
# 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.
# Install AT-SPI runtime bindings for Linux provider verification and rpm for builder’s fpm backend; stock Ubuntu lacks them.
- 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`.
# Derive literal stable/rc telemetry identity from strict tag shape; refuse unclassified builds rather than ship silent null identity (#1385 follow-up).
- name: Classify release tag for telemetry build identity
id: tag-classify
shell: bash
@@ -1448,9 +1240,7 @@ jobs:
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.
# Suffixed vX.Y.Z-rc.N.perf builds keep the same rc-channel telemetry identity.
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
@@ -1462,10 +1252,7 @@ jobs:
echo "identity=$identity" >>"$GITHUB_OUTPUT"
echo "Classified $TAG as $identity"
# Why an explicit step rather than trusting the runner image: the packaged
# CLI launcher is a native Rust binary, and a Windows host without cargo
# fails deep inside electron-builder's native hook instead of here. Real
# Windows dev machines do not have it by default either.
# Require cargo before builder invokes the packaged Rust launcher hook.
- name: Ensure the Rust toolchain for the Windows CLI launcher
if: matrix.platform == 'win'
shell: pwsh
@@ -1514,15 +1301,11 @@ jobs:
- 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.
# #8212: require Node and the exact Electron runtime to survive native 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
# Why: main ships minified with sourcemap:'hidden' and packaging drops
# out/**/*.map from app.asar, so a crash trace from a released build is
# otherwise undecodable. The main bundle is platform-independent, so one
# leg publishes the maps for the whole release.
# Publish hidden main sourcemaps separately because app.asar drops maps; one platform suffices for platform-independent main bundle.
- name: Bundle main-process source maps
id: bundle-main-sourcemaps
if: matrix.platform == 'linux-x64'
@@ -1532,8 +1315,7 @@ jobs:
run: |
set -euo pipefail
if [ -z "$(find out/main -name '*.js.map' -print -quit)" ]; then
# Older cut tags predate the hidden-source-map build setting. They
# are valid legacy releases, but have no map bundle to publish.
# Old valid cut tags predate hidden sourcemaps and have no bundle to publish.
if grep -Eq "sourcemap:[[:space:]]*['\"]hidden['\"]" electron.vite.config.ts; then
echo "::error::No main-process source maps in out/main despite build.sourcemap='hidden'."
exit 1
@@ -1543,9 +1325,7 @@ jobs:
exit 0
fi
echo "has_maps=true" >>"$GITHUB_OUTPUT"
# Why: every entry in electron-builder's `files` is a negation, so
# app-builder prepends `**/*` and packs anything left in the workspace
# root into app.asar. Stage the bundle outside the checkout instead.
# All-negation builder files prepend **/*; stage outside checkout to avoid packing maps into app.asar.
find out/main -name '*.js.map' -print | sort | zip -q -X "$RUNNER_TEMP/orca-sourcemaps-$TAG.zip" -@
ls -l "$RUNNER_TEMP/orca-sourcemaps-$TAG.zip"
@@ -1596,16 +1376,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The release command prepares and verifies this host's Electron native modules first.
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
# Why: the NSIS uninstaller only exists inside electron-builder's
# uninstaller pass, which deletes it right after embedding it. The sign
# hook in config/scripts/windows-uninstaller-signing.cjs copies it out
# here so it can ride the inner-binaries SignPath request below.
# Why runner.temp and never the workspace: `files` in
# config/electron-builder.config.cjs is all-negation, so app-builder
# prepends `**/*` and packs whatever is left in the checkout root. This
# step retries up to 3 times; attempt 1 writes the file after packing,
# but attempts 2 and 3 would then pack the unsigned uninstaller into
# app.asar - the exact defect this chain exists to remove.
# Capture NSIS uninstaller in sign hook before builder embeds/deletes it, including it in inner-binary signing. Keep capture under runner.temp: all-negation files includes checkout leftovers, and retry would pack unsigned uninstaller into app.asar.
ORCA_WIN_UNINSTALLER_EXPORT_PATH: ${{ runner.temp }}\uninstaller-signing\unsigned\orca-uninstaller.exe
- name: Verify Windows node-pty ConPTY runtime
@@ -1630,18 +1401,9 @@ jobs:
if: matrix.platform == 'win' && github.run_attempt == 1
uses: ./.github/actions/install-signpath-module
# ── 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. The NSIS uninstaller rides
# this same request (it is the MDE update cluster: old-uninstaller.exe /
# Uninstall Orca.exe), captured through electron-builder's sign hook and
# swapped back in during the rebuild — no third approval wait. Production
# releases require the entire signing chain to succeed (#23383).
# #7785/#23383: sign inner PE zip, including captured NSIS uninstaller, then rebuild/sign installer; SignPath cannot deep-sign inside NSIS and this avoids a third approval.
# Why: only unsigned PE files go to SignPath. Files that already carry a
# valid signature (Microsoft's OpenConsole.exe) must keep their signer.
# Stage only unsigned PE files; preserve existing vendor signatures such as Microsoft OpenConsole.
- name: Stage unsigned inner PE files for signing
id: stage-inner
if: matrix.platform == 'win' && github.run_attempt == 1
@@ -1685,12 +1447,8 @@ jobs:
Write-Host "Skipped $($skipped.Count) already-signed files:"
$skipped | ForEach-Object { Write-Host " $_" }
# Why the uninstaller rides this request: it is the file MDE flagged in
# the whole update cluster (old-uninstaller.exe / Uninstall Orca.exe),
# and folding it in here costs no extra approval wait. Why it is kept
# out of inner-signing-list.txt: that list drives the copy-back into
# dist/win-unpacked, and the uninstaller does not live there — it is
# re-injected through the sign hook during the rebuild instead.
# Include MDE-flagged old-uninstaller.exe/Uninstall Orca.exe in this approval without another wait.
# Exclude it from win-unpacked’s copy-back list; the sign hook re-injects it during rebuild.
$exportedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\unsigned\orca-uninstaller.exe'
if (-not (Test-Path -LiteralPath $exportedUninstaller)) {
throw "No exported NSIS uninstaller at $exportedUninstaller."
@@ -1822,9 +1580,7 @@ jobs:
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 before publication instead of silently
# shipping a mix of signed and unsigned binaries.
# Copy back only staged paths; layout mismatch must fail before mixed signed/unsigned assets ship.
- name: Restore signed inner binaries into unpacked app
id: restore-signed-inner
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.download-signed-inner.outcome == 'success'
@@ -1858,8 +1614,7 @@ jobs:
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
}
# Why: SignPath rewrote the template's Windows binaries, and the client materializer checks
# each file against the template manifest, so it must record the signed bytes.
# Recompute template checksums after SignPath rewrites Windows binaries so materialization validates signed bytes.
- name: Reseal the orcad template over its signed binaries
id: reseal-orcad-template
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' && needs.cut.outputs.ships_orcad_template == 'true'
@@ -1891,21 +1646,7 @@ jobs:
Copy-Item -LiteralPath $signed.FullName -Destination (Join-Path $signedDir 'orca-uninstaller.exe') -Force
Write-Host ("{0,-14} uninstaller <{1}>" -f $signature.Status, $signature.SignerCertificate.Subject)
# 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.
#
# The cache lookup lives in a script because the inline path this step used
# (`<cache>\nsis`) matches no app-builder-lib layout, and `SilentlyContinue`
# plus `exit 0` turned that miss into a green step — v1.4.193 and v1.4.194
# shipped an unsigned elevate.exe that way. A miss now fails the step.
# Builder recopies cached elevate.exe on every NSIS pack, clobbering signature in v1.4.129-rc.4; replace cache copy with signed bytes before rebuild. Persisted timestamped signatures may skip later staging; shipped-installer evidence still checks them. Scripted lookup fails missing cache paths: old nonexistent <cache>/nsis plus silent success shipped unsigned elevate.exe in v1.4.193/v1.4.194.
- name: Replace cached elevate.exe with the signed copy
id: sign-elevate-cache
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
@@ -1915,9 +1656,7 @@ jobs:
if (-not (Test-Path $signed)) {
throw 'No elevate.exe in win-unpacked resources.'
}
# Why this guard stays: windows-signing-rehearsal.yml shares the
# electron-builder-win-<lockfile hash> cache key with this workflow, so a
# test-certificate elevate.exe must never be staged into a release cache.
# Rehearsal shares the builder cache: never seed a release with its test-certificate elevate.exe.
$signature = Get-AuthenticodeSignature -FilePath $signed
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
@@ -2107,9 +1846,7 @@ jobs:
}
$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). Unsigned binaries block publication.
# #7785 evidence extracts every staged exe/dll/node from shipped installer, superseding #7170’s Orca.exe-only gate; unsigned assets block publish.
- name: Verify Windows inner binary signatures
if: matrix.platform == 'win' && github.run_attempt == 1
shell: pwsh
@@ -2162,10 +1899,8 @@ jobs:
$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.
# Resolve 7za via app-builder-lib; electron-builder 26.9+ dropped the
# bundled 7zip-bin package the old hardcoded path relied on (#6487).
# Verify shipped NSIS payload bytes via 7z, not the build tree. Resolve 7za through app-builder-lib:
# builder 26.9+ dropped bundled 7zip-bin (#6487).
$7zaOutput = node config/scripts/resolve-7za-path.mjs
$7zaExitCode = $LASTEXITCODE
if ($7zaExitCode -ne 0) {
@@ -2181,11 +1916,7 @@ jobs:
$root = Resolve-Path 'inner-evidence-extract'
# Verify the CLI even when a cached signed copy bypassed staging (#23383).
# 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.
# Always verify elevate.exe, even when cached signatures skipped staging; NSIS rebuild can clobber it.
$targets = @(Get-Content 'inner-signing-list.txt')
foreach ($requiredTarget in @('Orca.exe', 'resources\bin\orca.exe', 'resources\bin\orca-agent-hook.exe')) {
if ($targets -notcontains $requiredTarget) { $targets += $requiredTarget }
@@ -2193,17 +1924,9 @@ jobs:
if ($targets -notcontains 'resources\elevate.exe') {
$targets += 'resources\elevate.exe'
}
# Why the uninstaller is not in $targets: NSIS embeds it in its own
# compressed data section (`File /oname=${UNINSTALL_FILENAME}` in
# app-builder-lib templates/nsis/include/installer.nsh), not in the
# app 7z payload extracted above - the bundled 7za cannot see it.
# What the receipt proves and does not: the digest comparison is
# equal by construction (the hook digests the bytes it copied from
# this same file), so the real signal is that the receipt exists at
# all - the import leg ran, and these are the bytes it embedded. The
# signature check below is the part with teeth. The shipped-artifact
# check lives in windows-signing-rehearsal.yml, which installs the
# installer and inspects the uninstaller it drops on disk.
# The uninstaller lives in NSIS’s compressed data section, outside the app 7z payload.
# A receipt proves the sign-hook import ran, not the installed bytes; its digest equality is by construction.
# Check that input’s signature here; windows-signing-rehearsal installs/inspects the actual shipped uninstaller.
if ($env:UNINSTALLER_SIGNING_COMPLETED -eq 'true') {
$signedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\signed\orca-uninstaller.exe'
$receipt = "$signedUninstaller.embedded-sha256"
@@ -2246,10 +1969,7 @@ jobs:
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::warning::$_" }
$message = "Windows inner-binary evidence gate found $($failures.Count) problems."
# Why assigned before any I/O: a write that throws here would reach
# the catch with $policyFailure still null, so a required-mode
# signature failure would be re-reported as ERRORED and the per-file
# report overwritten — the exact masking the hoist exists to prevent.
# Set policyFailure before I/O: write errors must not turn FAILED into ERRORED and overwrite per-file evidence.
if ($required) {
$policyFailure = $message
} else {
@@ -2258,8 +1978,7 @@ jobs:
Add-GateEvidence "VERDICT: FAILED — $message"
Add-GateSummary "FAILED — $message"
} else {
# $report, not $targets: the embedded uninstaller is reported but
# is not one of the extracted payload targets.
# Use report, not extracted targets: it also includes the embedded uninstaller.
$ok = "All $($report.Count) checked binaries are signed by SignPath Foundation."
Add-GateEvidence "VERDICT: PASSED — $ok"
Add-GateSummary "PASSED — $ok"
@@ -2329,28 +2048,14 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Verify release remains draft after artifact upload
# Why: electron-builder `--publish always` can create a public release
# as soon as this platform uploads. Re-draft immediately, then fail, so
# /releases/latest never keeps serving a missing Windows exe.
# Why bash: the Windows matrix defaults to pwsh, which does not expand
# "$TAG" into argv.
# Re-draft immediately if publish:always exposed a partial release, then fail so latest cannot keep missing Windows assets. Use bash for $TAG expansion.
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.cut.outputs.tag }}
run: node config/scripts/assert-github-release-is-draft.mjs "${{ needs.cut.outputs.tag }}"
# Why post-pack for Linux: electron-builder packs and uploads in one
# `--publish always` invocation. The previous step re-drafts if that
# upload flipped the GitHub release public; this telemetry check still
# blocks `publish-release` from undrafting a bad binary.
#
# 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.
# Check Linux telemetry after inseparable pack/upload, then block final undraft. Reject null identity/key transport from missing configuration (#1385); prior step re-drafts premature publication.
- name: Verify telemetry constants present in app.asar
run: node config/scripts/verify-telemetry-constants.mjs
@@ -2371,9 +2076,7 @@ jobs:
needs.release-javascript.result == 'success' &&
(needs.orcad-template.result == 'success' ||
(needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false'))
# 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.
# Keep every SignPath workflow job GitHub-hosted; separate release-mac-build allows Blacksmith outside Windows provenance.
runs-on: ubuntu-latest
permissions:
actions: write
@@ -2496,22 +2199,14 @@ jobs:
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.
# Only undraft after every updater manifest and referenced installer exists.
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.
# Reassert prerelease from tag: on 2026-04-27 builder flipped RC to false/latest and broke release math; builder output is not authoritative.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.cut.outputs.tag }}
+12 -34
View File
@@ -47,9 +47,7 @@ jobs:
build-mac:
needs: qualification-template
if: ${{ !cancelled() && (!inputs.qualification_only || needs.qualification-template.result == 'success') && github.repository == 'stablyai/orca' }}
# Why: this workflow is outside the SignPath signing run, so Blacksmith
# cannot enter Windows artifact provenance while mac notarization gets the
# faster runner.
# Keep Blacksmith mac notarization outside SignPath’s GitHub-hosted Windows provenance.
runs-on: blacksmith-6vcpu-macos-15
timeout-minutes: 60
# actions: read fetches the release-cut run's artifacts.
@@ -97,8 +95,7 @@ jobs:
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# Why: packaging bundles the pinned Node into the macOS terminal host. Keyed on the pin alone
# with no restore-keys, so a pin bump starts empty instead of carrying old archives forward.
# Cache bundled host Node by exact pin, without restore keys.
- name: Locate the pinned Node runtime cache
shell: bash
run: echo "ORCA_NODE_RUNTIME_CACHE_DIR=$HOME/Library/Caches/orca-node-runtime" >>"$GITHUB_ENV"
@@ -109,8 +106,7 @@ jobs:
path: ~/Library/Caches/orca-node-runtime
key: orca-node-runtime-mac-${{ hashFiles('src/shared/node-runtime-pin.ts') }}
# Cache the Electron binary + electron-builder tool downloads (notarytool,
# winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac.
# Cache Electron/builder tool downloads to save ~30–90s.
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
@@ -121,12 +117,7 @@ jobs:
restore-keys: |
electron-builder-mac-
# 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.
# Why both CPUs: the mac config packages x64 and arm64 from this arm64
# runner, so the install must carry both variants of the native optional deps.
# Retry transient Electron CDN 504s; install both x64/arm64 optional natives for dual macOS packages.
- name: Install dependencies
uses: nick-fields/retry@v4
with:
@@ -135,8 +126,7 @@ jobs:
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile --cpu=current,x64,arm64
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules.
# Build required out/mobile-web using mobile’s React Native/Expo install.
- uses: ./.github/actions/install-mobile-dependencies
- name: Verify macOS signing environment
@@ -148,14 +138,11 @@ jobs:
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.
# Check duplicate entitlements before build: plutil lint accepts duplicates but codesign rejects them.
- name: Verify macOS entitlements
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.
# Set literal stable/rc telemetry identity through Vite define.
- name: Classify release tag for telemetry build identity
id: tag-classify
shell: bash
@@ -163,9 +150,7 @@ jobs:
TAG: ${{ inputs.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.
# Suffixed vX.Y.Z-rc.N.perf builds keep the same rc-channel telemetry identity.
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
@@ -216,16 +201,13 @@ jobs:
- name: Gate runtime file-watcher process isolation
run: |
# Why: #8212 is a native-process crash contract. Prove both the Node
# host and the exact Electron runtime survive SIGSEGV before packaging.
# #8212: require Node and the exact Electron runtime to survive native 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: Abort if the parent release-cut run was cancelled
if: ${{ !inputs.qualification_only }}
# Why: this workflow is dispatched separately, so cancelling release-cut
# does not stop mac `--publish always`. A cancelled parent left v1.4.206
# public with only a partial mac upload.
# Watch parent cancellation: separately dispatched mac upload otherwise continues (v1.4.206 went public with partial assets).
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PARENT_RUN: ${{ inputs.release_run_id }}
@@ -256,18 +238,14 @@ jobs:
- name: Verify release remains draft after artifact upload
if: ${{ !inputs.qualification_only }}
# Why: re-draft immediately if electron-builder flipped the GitHub
# release public, then fail. Checking without restoring leaves
# /releases/latest serving a missing Windows exe.
# Re-draft premature builder publication before failing; latest must not keep an incomplete Windows release.
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
run: node config/scripts/assert-github-release-is-draft.mjs "${{ inputs.tag }}"
# Why post-publish for macOS: 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.
# Check after publish because builder’s single pack/upload invocation has no cheap interposition point.
- name: Verify telemetry constants present in app.asar
run: node config/scripts/verify-telemetry-constants.mjs
+4 -7
View File
@@ -1,8 +1,7 @@
name: Terminal IME E2E
on:
# Why workflow_call and not pull_request: pr.yml owns the path filter that decides when an IME
# change is worth a real ibus session. A pull_request trigger here would run it on every PR.
# Let pr.yml own costly real-IBus path routing; a separate pull_request trigger would run every PR.
workflow_call:
workflow_dispatch:
inputs:
@@ -23,7 +22,7 @@ env:
jobs:
linux-x11:
name: Linux X11 terminal IME
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
timeout-minutes: 25
steps:
@@ -63,9 +62,7 @@ jobs:
--workers=1
- name: Run native IBus Hangul exact-byte tests
# Why not the default success(): the synthetic step above runs first, so its failure used
# to skip this one entirely — the real-IME half reported nothing on exactly the PRs that
# broke IME code. The two signals are independent and both belong in the log.
# Run native half despite synthetic failure: independent signals must both appear, rather than default success() hiding native evidence.
if: '!cancelled()'
env:
SKIP_BUILD: '1'
@@ -82,7 +79,7 @@ jobs:
linux-wayland:
name: Linux Wayland Hangul terminating digit
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
+1 -6
View File
@@ -1,11 +1,6 @@
name: Unit plan
# Why its own workflow: the caller's `needs` gate the whole called workflow, so while this lived
# inside unit-tests.yml it waited on static analysis and typecheck before it could even start —
# and the shards then waited on it. Planning needs neither (its inputs are the checkout, a git
# diff against HEAD^1, the import graph, and the checked-in timing baseline), so hoisting it out
# lets it overlap the gate instead of queueing behind it. Measured: a median 93s off the shard
# matrix's start.
# Plan independently of static/type gates so checkout/diff/import-graph/timing work overlaps them; moving it out of unit-tests advanced shards by median 93s.
on:
workflow_call:
inputs:
@@ -1,10 +1,6 @@
name: Unit selection evidence
# Why its own workflow: this job is advisory -- `continue-on-error` on both the job and its
# comparison step, so it can never fail a PR. But a caller's `needs: test` waits for every job in
# the called workflow, so while it lived in unit-tests.yml it held `verify` for ~36s after the
# last shard finished. Called as a sibling instead, it still runs on every PR and still uploads
# its review artifact; it just no longer sits on the critical path.
# Call advisory evidence beside unit-tests, preserving artifacts without delaying verify ~36s after shards; job/comparison remain continue-on-error.
on:
workflow_call:
+1 -8
View File
@@ -1,13 +1,6 @@
name: Windows Crash-Survival E2E
# Why: proves the detached daemon-host relocation makes terminal sessions SURVIVE
# a CRASH of Orca's main process (GitHub #7742), the companion guarantee to the
# update-survival harness. Builds an (unsigned) installer FROM THIS BRANCH,
# silent-installs it, opens a terminal, force-kills ONLY the app main (no
# tree-kill), and asserts the daemon + shell stay alive, a relaunch adopts the
# same daemon and shell, and post-crash input causes no pwsh FailFast. Targeted
# manual runs keep it as a durable regression proof. A CI runner is the only
# safe place to install — see the relocation post-mortem.
# #7742: build/install unsigned branch artifact on disposable CI, crash only main and require same daemon/shell survival, adoption and input without FailFast. Companion update proof tests installer survival; see relocation post-mortem.
on:
workflow_dispatch:
@@ -1,10 +1,6 @@
name: Windows packaged orcad serve switch E2E
# Why: proves D7 on a real Windows install. The installed desktop app forks its terminal daemon
# from the relocated %LOCALAPPDATA%\Orca\daemon-host; `orca serve` then serves the same profile
# on orcad and must adopt that daemon, and the desktop must reattach the terminal afterwards and
# still write its settings under orcad's data-root ACL. Unpackaged e2e cannot exercise the
# relocation, which only packaged builds do. A CI runner is the only safe place to install.
# D7 packaged Windows proof: relocated desktop daemon survives orcad serve adoption and desktop reattach, with settings writes after data-root ACL. Run install only on disposable CI; unpackaged E2E cannot exercise relocation.
on:
pull_request:
+3 -18
View File
@@ -1,20 +1,8 @@
name: Windows Update-Survival E2E
# Why: proves what happens to terminal sessions across a real Windows app update
# by installing one released version, updating to another, and asserting on the
# packaged artifacts (daemon survival, terminal interactivity, zero console
# flashes). This MUST run on a disposable CI Windows: electron-builder's oneClick
# installer uninstalls the registry-registered copy of the app before every
# install, so running the harness on a machine with a real Orca install would
# delete it. A fresh runner has no Orca registered, so it is the only safe home
# for install-based testing. Manual dispatch only.
# Install/update two released Windows builds and prove daemon/input/no-flash behavior on disposable CI only. oneClick uninstalls registry-selected app, so a real workstation install could be deleted.
# Why: workflow_dispatch only works once a workflow is on the default branch.
# To exercise this from the unmerged feature branch WITHOUT touching main, a
# push trigger scoped to that exact branch runs the workflow from the branch's
# own tree. The paths filter keeps it to harness/workflow edits so ordinary
# commits don't spend 30 min of Windows runner time. Push runs have no inputs,
# so every parameter falls back to a default below.
# Feature-branch push enables pre-merge workflow testing before dispatch exists on default; path-filter it to avoid 30min runs on unrelated changes. Push inputs use defaults.
on:
push:
branches:
@@ -113,10 +101,7 @@ jobs:
"FROM_EXE=$($from.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"TO_EXE=$($to.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
# Why: no --install-dir here. A CI runner has no real Orca registered, so
# the harness installs to the default per-user location and owns it — the
# isolated-install machinery exists only to (imperfectly) protect a real
# developer install, which does not apply on a throwaway runner.
# Own default per-user install on disposable CI; no real install needs the imperfect isolated-mode protection.
- name: Run update-survival harness
id: harness
shell: pwsh
+2 -12
View File
@@ -1,13 +1,6 @@
name: Windows Update-Survival E2E (branch build)
# Why: proves the Phase 1 daemon-host relocation actually makes terminal
# sessions SURVIVE a Windows update. Builds an (unsigned) installer FROM THIS
# BRANCH, then runs the win-update-e2e harness with --expect survival: install,
# open a terminal, silently update over it, relaunch, and assert the daemon PID
# is unchanged and the pre-update terminal is still interactive with no console
# flashing. Runs from the feature branch via push (workflow_dispatch only works
# on the default branch); main is never touched. A CI runner is the only safe
# place to install/update — see the relocation post-mortem.
# Build/install/update unsigned branch artifact with --expect survival; require same daemon and interactive original terminal without flashes. Feature push tests pre-default-branch workflow; run only on disposable CI (relocation post-mortem).
on:
push:
@@ -78,10 +71,7 @@ jobs:
with:
install: false
# Why: cache the built installer keyed on the inputs that affect it, so a
# harness-only edit skips the ~20 min electron-builder build. The daemon
# relocation code lives under src/, so changing it correctly rebuilds.
# Hash before installation creates native build artifacts under native/.
# Cache installer by product inputs so harness-only changes skip ~20min build; hash before native install generates artifacts.
- name: Cache branch installer
id: cache-installer
uses: actions/cache/restore@v4
+24 -105
View File
@@ -1,18 +1,4 @@
# Windows inner-binary signing rehearsal.
#
# Why: SignPath cannot deep-sign inside NSIS installers, so shipping signed
# inner binaries (Orca.exe, node-pty *.node, DLLs — see issue #7785) requires
# a two-request flow: sign the unpacked PE files first, then build the NSIS
# installer from the signed tree, then sign the installer. The NSIS uninstaller
# rides that same first request — it is captured through electron-builder's sign
# hook and swapped back in during the rebuild — so it adds no third approval.
# This workflow rehearses that entire flow from a branch, end to end, without
# publishing anything — so the release pipeline on main is never at risk.
#
# Runs only via manual dispatch. Use the test-signing policy for iteration
# (auto-approved test certificate) and release-signing to rehearse the
# production flow (requires manual SignPath approvals). Rehearsed successfully
# with test-signing (run 28987534795) and release-signing (run 28988432001).
# Rehearse #7785 inner-PE/uninstaller signing then rebuilt installer signing without publication. SignPath cannot deep-sign NSIS; captured uninstaller shares first request. Manual test-signing auto-approves; release-signing needs production review. Proven runs 28987534795/28988432001.
name: Windows signing rehearsal
on:
@@ -93,16 +79,11 @@ jobs:
env:
NODE_OPTIONS: --max-old-space-size=4096
# Why a full --win build and not --dir: the NSIS uninstaller only exists
# inside the installer build, and it is the file the MDE update cluster
# flags. --dir would never produce it, so the rehearsal would not rehearse
# the uninstaller leg at all. This mirrors release-cut's first Windows pass.
# Build full --win: --dir never creates the NSIS uninstaller needed for MDE evidence.
- name: Package Windows app and export the NSIS uninstaller
shell: pwsh
env:
# runner.temp, never the workspace: the all-negation `files` list in
# config/electron-builder.config.cjs packs whatever is left in the
# checkout root into app.asar.
# Keep capture under runner.temp; all-negation files would include checkout leftovers in app.asar.
ORCA_WIN_UNINSTALLER_EXPORT_PATH: ${{ runner.temp }}\uninstaller-signing\unsigned\orca-uninstaller.exe
run: |
node config/scripts/ensure-native-runtime.mjs --runtime=electron
@@ -116,9 +97,7 @@ jobs:
throw "The sign hook did not export the NSIS uninstaller to $env:ORCA_WIN_UNINSTALLER_EXPORT_PATH"
}
# Why: only unsigned PE files go to SignPath. Files that already carry a
# valid signature (Microsoft's OpenConsole.exe, signed vendor DLLs) must
# keep their original signer, so they are excluded from the request.
# Sign only unsigned PE files; retain vendor signatures such as Microsoft OpenConsole.
- name: Stage unsigned PE files for inner signing
id: stage-inner
shell: pwsh
@@ -156,11 +135,8 @@ jobs:
Write-Host "Skipped $($skipped.Count) already-signed files:"
$skipped | ForEach-Object { Write-Host " $_" }
# Why kept out of inner-signing-list.txt: that list drives the copy-back
# into dist/win-unpacked, and the uninstaller does not live there — it is
# re-injected through the electron-builder sign hook during the rebuild.
# No catch here, unlike the release job: the rehearsal exists to prove
# the flow, so a staging failure must fail it loudly.
# The uninstaller is re-injected by the sign hook, outside win-unpacked’s copy-back list.
# Fail staging loudly: rehearsal proves the flow and needs no release-job recovery catch.
$exportedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\unsigned\orca-uninstaller.exe'
$uninstallerStagePath = Join-Path $stage.FullName 'uninstaller\orca-uninstaller.exe'
New-Item -ItemType Directory -Force -Path (Split-Path $uninstallerStagePath) | Out-Null
@@ -206,9 +182,7 @@ jobs:
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 instead of silently shipping a mix of
# signed and unsigned binaries.
# Copy back strictly by staged list so mismatches fail instead of shipping mixed signing.
- name: Restore signed inner binaries into unpacked app
shell: pwsh
env:
@@ -343,10 +317,7 @@ jobs:
$latestYml = $latestYml -replace '(?m)^ blockMapSize: \d+$', " blockMapSize: $($blockmap.Length)"
Set-Content -Path 'dist/latest.yml' -Value $latestYml -NoNewline
# Why: this is the pass/fail heart of the rehearsal — the same evidence
# check that will later gate releases (see PR #7170). Test certificates
# do not chain to a trusted root, so Status=Valid is only required for
# release-signing runs; test-signing runs require a present signature.
# Use production evidence gate (#7170); require trusted Status=Valid only for release signing, signature presence for test certs.
- name: Verify signatures end to end
shell: pwsh
env:
@@ -357,10 +328,7 @@ jobs:
$advisories = New-Object System.Collections.Generic.List[string]
$requireValid = $env:SIGNING_POLICY -eq 'release-signing'
# -Advisory records a problem without failing the run. It exists for
# exactly one file (resources\elevate.exe, below) and must not be
# widened casually: the point of this workflow is to fail when signing
# is broken.
# Advisory is only for resources\elevate.exe; all other signing failures must fail this rehearsal.
function Test-Signature([string]$label, [string]$path, [switch]$Advisory) {
$signature = Get-AuthenticodeSignature -FilePath $path
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
@@ -386,9 +354,7 @@ jobs:
Test-Signature 'installer: orca-windows-setup.exe' 'dist/orca-windows-setup.exe'
# Extract the signed installer and verify the files a user actually
# gets on disk — including the exact file from issue #7785.
# electron-builder 26.9+ removed the bundled 7zip-bin package (#6487).
# Extract/verify shipped bytes, including #7785. Builder 26.9+ removed bundled 7zip-bin (#6487).
$7zaOutput = node config/scripts/resolve-7za-path.mjs
$7zaExitCode = $LASTEXITCODE
if ($7zaExitCode -ne 0) {
@@ -402,13 +368,8 @@ jobs:
& $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null
$root = Resolve-Path 'extracted-app'
# The receipt only proves the import leg ran; it cannot prove what NSIS
# embedded, because the uninstaller lives in a compressed NSIS data
# section rather than the app 7z payload above and the bundled 7za has
# no NSIS handler. So the rehearsal - unlike the release job, which
# must not mutate the runner it publishes from - goes all the way: it
# installs the installer silently and inspects the uninstaller the
# installer actually wrote to disk. That is the file MDE flags.
# The receipt proves import, not NSIS embedding: the uninstaller lies outside the app 7z payload.
# Unlike publishing release jobs, rehearsal can install silently and inspect the actual MDE-flagged file.
$signedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\signed\orca-uninstaller.exe'
$receipt = "$signedUninstaller.embedded-sha256"
if (-not (Test-Path -LiteralPath $receipt)) {
@@ -417,10 +378,7 @@ jobs:
Test-Signature 'relayed: orca-uninstaller.exe' $signedUninstaller
}
# Why a full 7-Zip attempt first: it is non-invasive. The runner image
# ships the complete 7z.exe, which - unlike the reduced 7za - has an
# NSIS handler. If it cannot read the section either, fall back to a
# real silent install.
# Try full 7z.exe’s NSIS handler non-invasively first; reduced 7za lacks it. Fall back to a real silent install.
$installedUninstaller = $null
$installedVia = $null
$expectedDigest = if (Test-Path -LiteralPath $receipt) { (Get-Content -LiteralPath $receipt -Raw).Trim() } else { $null }
@@ -430,14 +388,8 @@ jobs:
& $full7z x -tnsis 'dist/orca-windows-setup.exe' '-onsis-extract' -y 2>&1 | Out-Null
$installedUninstaller = Get-ChildItem -Path nsis-extract -Recurse -File -Filter 'Uninstall*.exe' -ErrorAction SilentlyContinue |
Select-Object -First 1
# Why the digest guard before trusting this route: 7-Zip's NSIS
# handler emits partial or garbled output on some NSIS builds, and a
# truncated extract would score NotSigned and fail the rehearsal as
# "the shipped uninstaller is unsigned" when nothing is wrong. Only
# trust it when it reproduces the bytes the relay embedded; otherwise
# fall through to the install route, which is ground truth. A name
# miss (the handler labelling the entry by its source name) falls
# through the same way.
# NSIS extraction can be partial/garbled: trust 7z only when its digest matches embedded bytes,
# otherwise use ground-truth installation. Missing/misnamed entries also fall through.
if ($null -ne $installedUninstaller -and $null -ne $expectedDigest -and
(Get-FileHash -LiteralPath $installedUninstaller.FullName -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expectedDigest) {
Write-Host "7-Zip's NSIS output did not match the relayed digest; falling back to a silent install."
@@ -452,19 +404,13 @@ jobs:
}
if ($null -eq $installedUninstaller) {
# Nothing here is published, so mutating this runner is free.
# Why -PassThru and a bounded wait rather than -Wait: a bare -Wait on
# an installer that ever prompts hangs to the job's 360-minute cap.
# No artifacts are published, so installation is safe here. Bound -PassThru waits; -Wait could hang on a prompt for 360 minutes.
$installerProcess = Start-Process -FilePath (Resolve-Path 'dist/orca-windows-setup.exe') -ArgumentList '/S' -PassThru
if (-not $installerProcess.WaitForExit(300000)) {
$installerProcess | Stop-Process -Force -ErrorAction SilentlyContinue
$failures.Add('the silent install did not exit within 5 minutes; it is likely prompting')
}
# Why a poll rather than one Stop-Process: the oneClick installer
# launches the app as it finishes, so Orca.exe can appear *after* the
# installer process exits. A single silenced Stop-Process would miss
# it and leave Orca plus orca-terminal-daemon.exe holding handles
# under %LOCALAPPDATA%\Programs for the rest of the job.
# Poll app shutdown: oneClick can launch Orca after installer exit, leaving app/daemon handles in LOCALAPPDATA\Programs.
for ($attempt = 0; $attempt -lt 20; $attempt++) {
$running = @(Get-Process -Name 'Orca' -ErrorAction SilentlyContinue)
if ($running.Count -gt 0) {
@@ -484,12 +430,8 @@ jobs:
if ($null -eq $installedUninstaller) {
$failures.Add('could not obtain the uninstaller the installer ships; neither 7-Zip nor a silent install produced it')
} else {
# Why this digest comparison is the point of the whole rehearsal:
# unlike the release job's, it hashes a file NSIS itself wrote out
# rather than the file the hook copied, so it is the only check that
# proves the shipped installer embedded the SignPath-signed bytes. On
# the 7-Zip route the guard above already forced equality; on the
# install route this is the first time it is tested.
# Hash NSIS-written bytes to prove the installer embedded SignPath-signed input; the release hook’s
# input-to-input comparison cannot prove this. The 7z route already requires digest equality.
if ($null -ne $expectedDigest) {
$shippedDigest = (Get-FileHash -LiteralPath $installedUninstaller.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
if ($shippedDigest -ne $expectedDigest) {
@@ -509,34 +451,11 @@ jobs:
$failures.Add("missing from installer payload: $relative")
continue
}
# Why elevate.exe alone is advisory: app-builder-lib re-copies the
# pristine cached elevate.exe over resources\elevate.exe on EVERY nsis
# pack - AppPackageHelper.packArch calls elevateHelper.copy() before
# buildAppPackage (nsisUtil.js), and CopyElevateHelper.copy does
# `copyFile(elevatePath, outFile, false)` then `signIf(outFile)`, which
# signs nothing because this build configures no certificate. So the
# signed copy restored into win-unpacked is clobbered by the rebuild.
# This predates the uninstaller relay and is not caused by it: with no
# `sign` hook, signIf already returned false at "no signing info
# identified" (windowsSignToolManager.js), so no signtool call was
# displaced. release-cut.yml mitigates it separately by pre-seeding the
# electron-builder cache ("Replace cached elevate.exe with the signed
# copy"); this workflow has no such step, which is why the clobber is
# visible here and not there. Mirroring that step here would not help:
# it only swaps when the copy is already Valid and SignPath-signed, so
# it no-ops under the test certificate.
#
# DO NOT relax that Valid + SignPath-signed guard to make this
# rehearsal go green. This workflow and release-cut.yml share the
# cache key `electron-builder-win-<lockfile hash>`, and that guard is
# the only thing stopping a test certificate from being seeded into
# the cache a real release restores from. Shipping users a binary
# signed by "Test certificate for 'Orca agent ide [OSS]'" is worse
# than shipping it unsigned.
#
# Fixing elevate.exe belongs in its own PR - it is a UAC elevation
# helper, and it deserves more scrutiny than a footnote in an
# uninstaller change.
# Only elevate.exe is advisory: NSIS pack recopies pristine cache bytes over signed win-unpacked output.
# With no certificate, CopyElevateHelper.signIf signs nothing; this predates the uninstaller relay.
# release-cut pre-seeds only Valid, SignPath-signed cache bytes; test certificates deliberately fail that guard.
# DO NOT relax it: rehearsal shares electron-builder-win-<lockfile hash> with real releases and would poison
# their cache with “Test certificate for Orca agent ide [OSS]”. Fix the UAC helper separately with proper scrutiny.
if ($relative -eq 'resources\elevate.exe') {
Test-Signature "installed: $relative" $path -Advisory
} else {