[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
@@ -1,17 +1,6 @@
import { execFileSync } from 'node:child_process'
// DESIGN CHOICE: shell out to `gcloud auth print-access-token` instead of exchanging the
// external_account credentials file that google-github-actions/auth writes.
//
// Every workflow on the Cloud SQL rollout lease already runs google-github-actions/setup-gcloud
// right after auth (verified across all 11 mutating members), so gcloud is on PATH and already
// bound to the federated identity. Doing the exchange ourselves would mean reimplementing the STS
// token swap plus the service-account impersonation leg, in an action that must stay
// zero-dependency and is duplicated by hand into a second repo. gcloud already handles every ADC
// flavour and refreshes on its own. The metadata server is not an option: it does not exist on
// GitHub or Blacksmith runners.
//
// Consequence, documented in the README: the lease step MUST come after setup-gcloud.
// Use gcloud after setup-gcloud: it handles federated STS/impersonation and ADC refresh without dependencies or repo-local code in this duplicated action. Actions/Blacksmith runners have no metadata server.
const TOKEN_REUSE_MS = 40 * 60 * 1_000 // GCP access tokens live ~60 min; re-mint well before that.
@@ -1,5 +1,4 @@
// Who we claim to be on the lease object. Shared by main and the detached renewer so both agree
// on the holder key without re-deriving it from a different set of environment variables.
// Main and detached renewer must derive the same holder key from one environment contract.
export function holderIdentity(explicitHolderKey) {
const repository = process.env.GITHUB_REPOSITORY ?? 'unknown'
@@ -3,8 +3,7 @@ import { createAccessTokenSource } from './gcloud-access-token.mjs'
import { renewerLogPath } from './runner-state.mjs'
import { CloudSqlRolloutLease, RENEW_INTERVAL_MS } from './storage-lease.mjs'
// Detached renewer. `main` spawns it, `post` kills it. It rewrites expires_at on the same
// generation-matched path as acquisition, and stops the moment the object stops being ours.
// Detached renewer shares acquisition’s generation-matched writes; stop when ownership changes. Main starts it and post kills it.
const MAX_LIFETIME_MS = 6 * 60 * 60 * 1_000 // Backstop if post never runs (runner killed).
@@ -1,7 +1,6 @@
import { appendFileSync } from 'node:fs'
// Action-state and output plumbing via the runner's file protocol, so the action needs no
// @actions/core dependency. Values are single-line by construction; anything else is rejected.
// Use runner files without @actions/core; reject multiline values rather than corrupting their protocol.
/** The detached renewer's stdio is ignored, so it appends here instead and `post` echoes it. */
export function renewerLogPath() {
@@ -1,10 +1,4 @@
// Compare-and-swap lease over a single Cloud Storage object.
//
// Ported from apps/relay-fence-broker/src/mutation-lease.ts rather than imported: this action is
// duplicated verbatim into stablyai/orca, so it must carry no repo-local imports. Only the
// algorithm is shared (read metadata -> write with ifGenerationMatch -> 412 is a conflict ->
// generation-matched delete -> an expired record is free). The broker's token path is NOT shared;
// it reads the GCE metadata server, which does not exist on Actions runners.
// Ported CAS algorithm from relay-fence-broker/mutation-lease.ts: keep repo-local imports out of this duplicated action. Broker metadata-server tokens cannot work on Actions runners; use generation-matched writes/deletes, 412 conflicts and expiry.
export const LEASE_TTL_MS = 35 * 60 * 1_000
export const RENEW_INTERVAL_MS = 5 * 60 * 1_000
@@ -12,10 +12,7 @@ const BUCKET = 'onorca-cloud-terraform-state'
const OBJECT = 'terraform/state/cloud-sql-rollout/production.lock'
const NOW = 1_756_000_000_000
/**
* Enough of the Cloud Storage JSON API to exercise real compare-and-swap semantics: generations
* increment, ifGenerationMatch is enforced, and a mismatch is a 412. `faults` injects failures.
*/
/** Model real GCS generation increments, ifGenerationMatch and 412 conflicts; faults inject failures. */
function fakeStorage({ object = null, faults = [] } = {}) {
const state = { object, requests: [] }
const fetcher = async (rawUrl, init = {}) => {
@@ -4,12 +4,7 @@ description: Frozen pnpm install for the mobile/ project, whose node_modules the
runs:
using: composite
steps:
# Why a separate install: mobile is its own pnpm project, so the root install leaves
# mobile/node_modules empty and every mobile import resolves to nothing.
# Why no --ignore-scripts, unlike the root install: mobile's postinstall generates the
# gitignored terminal/mermaid webview engine modules that tracked source imports.
# The drift guard mirrors the root install so a stale mobile lockfile fails by name --
# mobile's lockfile carries patchedDependencies that a silent rewrite would drop.
# Install the separate mobile project: root install leaves its imports unresolved. Keep lifecycle scripts for generated terminal/mermaid engines; reject lockfile drift that could drop patchedDependencies.
- name: Install mobile dependencies
shell: bash
working-directory: mobile
@@ -222,10 +222,7 @@ runs:
rm -rf node_modules
fi
# Why --frozen-lockfile: re-resolving pulls ~62 MB of registry packuments per job
# (measured) to recompute what the lockfile already pins, and the `git diff` guard
# below fails the run whenever that recomputation would have changed anything. The
# guard stays so a stale lockfile still fails by name rather than by resolver error.
# Frozen install avoids ~62MB/job of redundant resolution; retain diff guard to name stale lockfiles explicitly.
- name: Install dependencies
shell: bash
run: |
@@ -29,13 +29,7 @@ runs:
# Why: force TLS 1.2 so gallery downloads work on older hosted images.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
# Why: on some hosted Windows images `Register-PSRepository -Default`
# fails inside the legacy nuget.exe provider with "Missing option value
# for: '-source'", so PSGallery is never registered and the install
# below dies with "No repository with the name 'PSGallery'". PSResourceGet
# (bundled with PowerShell 7.4+) has PSGallery registered by default and
# avoids that code path, so prefer it and fall back to PowerShellGet only
# when it is absent.
# Prefer PSResourceGet (PowerShell 7.4+): legacy nuget Register-PSRepository can fail with “Missing option value for: -source” and leave PSGallery absent.
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
try {
@@ -53,9 +47,7 @@ runs:
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
}
} catch {
# Why: repository registration also talks to the gallery, so a gallery
# outage can fail here before a single install is attempted. The CDN
# fallback below does not need a registered repository, so keep going.
# Repository registration can fail during gallery outages; continue so the independent CDN fallback remains reachable.
Write-Warning "PSGallery repository registration failed: $_"
}
@@ -114,13 +106,8 @@ runs:
}
}
# Why: the gallery's package API (OData search + repository metadata) sits
# behind Azure Front Door and has returned 403/502/504 for every install
# attempt during gallery incidents, which hard-failed the whole Windows
# release job. The CDN that serves the nupkg itself is a separate origin
# and stays up through those incidents, so fall back to a pinned version
# fetched straight from it. The hash pin is mandatory: this route skips the
# gallery's package validation, so an unexpected payload must fail loudly.
# Gallery OData/metadata has returned 403/502/504 while the independent nupkg CDN stayed up.
# Fall back to a pinned version and mandatory hash because this bypasses gallery validation.
if (-not $installed) {
$version = $env:SIGNPATH_FALLBACK_VERSION
$expectedHash = $env:SIGNPATH_FALLBACK_SHA256
@@ -131,9 +118,7 @@ runs:
Remove-Item -LiteralPath $nupkg -Force
}
# Why two URLs: the /api/v2/package route 302s to the CDN and can serve
# while the OData search endpoint is failing; the CDN URL is the same
# redirect target reached directly when the api host is down entirely.
# Try the API’s CDN redirect, then the CDN directly if the API host is down.
$sources = @(
"https://www.powershellgallery.com/api/v2/package/SignPath/$version",
"https://cdn.powershellgallery.com/packages/signpath.$version.nupkg"
@@ -173,9 +158,7 @@ runs:
}
Remove-SignPathModuleDirectory
# Why a version-named subdirectory: PowerShell only treats a nested folder
# as a side-by-side module version when the name matches the manifest's
# ModuleVersion, which is what makes `Import-Module SignPath` resolve it.
# PowerShell resolves side-by-side modules only when the directory matches the manifest’s ModuleVersion.
$versionRoot = Join-Path -Path $signPathModulePath -ChildPath $version
New-Item -ItemType Directory -Path $versionRoot -Force | Out-Null
Expand-Archive -LiteralPath $nupkg -DestinationPath $versionRoot -Force
+1 -2
View File
@@ -1,7 +1,6 @@
version: 2
updates:
# Why only ripgrep: the bundled rg ships in every artifact and to SSH remotes, and a bump is a
# one-line pin change (the SSH cache keys on the binary's hash). Other dependencies stay manual.
# Automate only bundled ripgrep’s single pin; its hash invalidates SSH cache. Other dependency updates stay manual.
- package-ecosystem: npm
directory: /
schedule:
@@ -2,10 +2,7 @@ import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
function readRootEntries(sha) {
// Why: a git pathname is arbitrary bytes, and 'utf8' folds every invalid
// sequence to U+FFFD — that mangles the reported name and makes two different
// entries compare equal, so a genuinely new one can slip past the Set below.
// latin1 maps each byte to one code unit, so the bytes survive the round trip.
// Git paths are arbitrary bytes; latin1 round-trips them while utf8 merges invalid sequences into U+FFFD, hiding distinct entries.
const stdout = execFileSync('git', ['ls-tree', '-z', '--name-only', sha], {
encoding: 'latin1',
stdio: ['ignore', 'pipe', 'inherit']
@@ -42,10 +39,7 @@ function checkRootDirectoryEntries(argv) {
)
console.log('Move each new entry under an existing top-level directory.')
console.log('Blocked entries:')
// Why: an entry name is attacker-controlled and may start with '::' (the runner
// trims leading spaces before matching) or embed a newline, so printing it bare
// lets a PR forge annotations. Fence the untrusted list with an unguessable
// stop-commands token, and write the raw bytes rather than a re-encoded string.
// Untrusted names can forge runner commands/newlines; fence them with a random stop-commands token and preserve raw bytes.
const resumeToken = randomUUID()
console.log(`::stop-commands::${resumeToken}`)
for (const entry of blockedEntries) {
@@ -56,12 +50,10 @@ function checkRootDirectoryEntries(argv) {
}
try {
// Why: process.exit truncates a piped write part-way through on macOS, so set
// exitCode and let node flush the blocked-entry list before it exits.
// Let piped output flush; process.exit can truncate it on macOS.
process.exitCode = checkRootDirectoryEntries(process.argv.slice(2))
} catch (error) {
// Why: git already reported the failure on the inherited stderr, so surface its
// status rather than a node stack trace. Anything else is a real bug — rethrow.
// Git already reported failures; preserve its exit status and rethrow other bugs.
if (typeof error.status !== 'number') {
throw error
}
+1 -3
View File
@@ -3,9 +3,7 @@ export const LOC_BLOCK_END = '<!-- /orca-pr-loc -->'
export const LOC_HANDS_OFF_COMMENT =
'<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->'
// Why the __-wrapped names: they are unambiguous test-data markers, so a dir
// cannot be one by accident. Bare `fixtures/` is deliberately absent — it reads
// as a plausible prod module name, and this classifier bills every PR.
// __-wrapped segments unambiguously mark test data; bare fixtures/ could be production, and this classifier bills every PR.
const TEST_DIR_SEGMENT = /(?:^|\/)(?:__tests__|__fixtures__|__snapshots__|e2e|tests)(?:\/|$)/i
const TEST_FILENAME = /\.(?:test|spec|e2e)\.[^/]+$/i
+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 {
+3 -13
View File
@@ -15,27 +15,17 @@ cask "orca" do
strategy :github_latest
end
# Why: electron-updater (src/main/updater.ts) handles in-place updates by
# writing a new Orca.app into /Applications. Marking the cask auto_updates
# tells Homebrew not to compete with the in-app updater — `brew upgrade`
# becomes a no-op unless the user passes --greedy, and brew's version
# metadata stays aligned with whatever the app has swapped itself to.
# Let the in-app updater own updates; Homebrew skips auto_updates unless --greedy.
auto_updates true
conflicts_with cask: "orca@rc"
depends_on macos: :big_sur
app "Orca.app"
# Why: expose the bundled `orca` CLI on PATH at install time (Homebrew symlinks
# this into its already-on-PATH bin dir). Without it, the CLI is only registered
# by the in-app "Install CLI" action, which a headless host can never trigger —
# so `orca serve` on a server would be unreachable from the shell. The shim
# resolves the real app by walking symlinks, so the Homebrew symlink works.
# Expose the symlink-resolving bundled CLI on PATH so headless hosts can use orca serve without the in-app installer.
binary "#{appdir}/Orca.app/Contents/Resources/bin/orca"
# Why: Orca writes user data under ~/.orca (worktrees, agent state) and
# Electron's standard userData directories. Zap removes everything the app
# creates during normal use so `brew uninstall --zap` is a clean slate.
# Zap app-created ~/.orca and Electron userData for a clean uninstall.
zap trash: [
"~/.orca",
"~/Library/Application Support/Orca",
+3 -10
View File
@@ -26,24 +26,17 @@ cask "orca@rc" do
end
end
# Why: RC installs should follow Orca's prerelease-aware updater instead of
# waiting for Homebrew metadata churn between frequent release candidates.
# Use Orca’s prerelease-aware updater between frequent RCs.
auto_updates true
conflicts_with cask: "orca"
depends_on macos: :big_sur
app "Orca.app"
# Why: expose the bundled `orca` CLI on PATH at install time (Homebrew symlinks
# this into its already-on-PATH bin dir). Without it, the CLI is only registered
# by the in-app "Install CLI" action, which a headless host can never trigger —
# so `orca serve` on a server would be unreachable from the shell. The shim
# resolves the real app by walking symlinks, so the Homebrew symlink works.
# Expose the symlink-resolving bundled CLI on PATH so headless hosts can use orca serve without the in-app installer.
binary "#{appdir}/Orca.app/Contents/Resources/bin/orca"
# Why: Orca writes user data under ~/.orca (worktrees, agent state) and
# Electron's standard userData directories. Zap removes everything the app
# creates during normal use so `brew uninstall --zap` is a clean slate.
# Zap app-created ~/.orca and Electron userData for a clean uninstall.
zap trash: [
"~/.orca",
"~/Library/Application Support/Orca",
+2 -4
View File
@@ -6,16 +6,14 @@ description = "Explicit Relay test signing key"
regexTarget = "secret"
regexes = ['''^test-assignment-key-with-at-least-32-bytes$''']
# The Cloud SQL rollout lease records `owner/repo/run_id` as the holder of a lease. The action's
# unit tests build fixture holders from that shape, which the generic key rule reads as a secret.
# Fixture lease holders use owner/repo/run_id, which the generic key rule mistakes for a secret.
[[allowlists]]
description = "Cloud SQL rollout lease holder keys in the action's unit tests"
regexTarget = "secret"
paths = ['''\.github/actions/cloud-sql-rollout-lease/[a-z-]+\.test\.mjs$''']
regexes = ['''^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/[0-9]+$''']
# RFC 6455 §1.3 example handshake nonce ("the sample nonce" in base64), sent by the raw-socket
# upgrade tests; the generic key rule reads any base64 header value as a secret.
# Raw-socket tests use RFC 6455 §1.3’s example base64 nonce, triggering the generic key rule.
[[allowlists]]
description = "RFC 6455 example Sec-WebSocket-Key in upgrade tests"
regexTarget = "secret"
+1 -2
View File
@@ -11,8 +11,7 @@ export type ApnsRequest = {
export type { ApnsResponse }
export type ApnsTransport = (request: ApnsRequest) => Promise<ApnsResponse>
// APNs requires HTTP/2 and rewards a long-lived session per host, so sessions
// are cached and only dropped when the socket itself goes away.
// Cache APNs HTTP/2 sessions per host until socket loss; long-lived connections avoid reconnect overhead.
export function createApnsHttp2Transport(): ApnsTransport & { close(): void } {
const sessions = new Map<string, ClientHttp2Session>()
+2 -4
View File
@@ -4,8 +4,7 @@ import { constants } from 'node:http2'
export type ApnsResponse = { status: number; body: string; retryAfterMs?: number }
// The subset of ClientHttp2Stream this module drives, so a fake emitter can
// stand in for a real APNs stream in tests.
// Stream subset permits fake emitters in APNs tests.
export type ApnsResponseStream = EventEmitter & {
setTimeout(ms: number, callback: () => void): void
destroy(error?: Error): void
@@ -45,8 +44,7 @@ export function readApnsStreamResponse(
})
)
)
// A peer reset with NGHTTP2_NO_ERROR emits neither 'end' nor 'error', which
// would leave the worker's delivery pending for the life of the process.
// NGHTTP2_NO_ERROR resets emit neither end nor error; close must settle or delivery hangs for the process lifetime.
stream.on('close', () => settle(() => reject(new Error('apns_stream_closed'))))
stream.end(body)
})
+1 -7
View File
@@ -14,13 +14,7 @@ export type ClientIpRateLimiterOptions = {
type Bucket = { tokens: number; updatedAt: number }
// Read x-forwarded-for from the right. Cloud Run appends the connecting peer,
// so the last value is the only one it wrote; everything to its left is
// whatever the caller sent and can be a fresh forgery on every request.
// trustedProxyHops is how many appenders sit between Cloud Run and the client
// (0 today, 1 once a load balancer fronts it). A header too short for that
// depth is not trusted at all and falls through to the shared bucket, which
// throttles rather than opens.
// Read from the right: Cloud Run appends the peer, while callers can forge earlier entries. Skip trusted proxy hops (0 direct, 1 behind LB); short chains share the throttled fallback bucket.
export function readClientIp(context: Context, trustedProxyHops = 0): string {
const hops =
context.req
@@ -6,9 +6,7 @@ import { PushHostChallengeStore } from './host-challenge-store.js'
import { deriveHostFingerprint } from './host-fingerprint.js'
import { openInMemoryPushDatabase } from './push-database.js'
// Why: the desktop answers challenges in a workspace this one cannot import.
// Both sides replay the same checked-in vector, so a transcript drift on
// either side fails in that side's own suite.
// Desktop is a separate workspace; replay one checked-in vector in both suites to catch transcript drift.
describe('desktop host proof interop', () => {
it('the checked-in vector answers to the same proof the fixture host computes', () => {
const secretKey = new Uint8Array(Buffer.from(vector.hostSecretKeyB64, 'base64'))
+1 -2
View File
@@ -52,8 +52,7 @@ export class PushDeviceRegistryStore {
private readonly now: () => number = Date.now
) {}
// The registration id is stable for a (host, device) pair so a re-registered
// phone keeps the id the desktop already persisted; only the token rotates.
// Stable host/device registration IDs preserve desktop references across phone re-registration; only tokens rotate.
async upsert(input: PushDeviceUpsert): Promise<PushDeviceUpsertResult> {
const now = this.now()
return await this.database.transaction<PushDeviceUpsertResult>(async (transaction) => {
+2 -4
View File
@@ -109,8 +109,7 @@ export class DurablePushStore {
async claim(canClaim?: () => boolean): Promise<QueuedPushDelivery | null> {
return this.claims.transaction(async (tx) => {
if (canClaim && !canClaim()) return null
// The previous revision's claim holds this key exclusively; sharing it makes that claim snapshot
// only after our leases commit. Drop one release after every worker runs this revision.
// Share the previous revision’s exclusive claim key so it snapshots after our leases commit; drop one release after all workers upgrade.
if (!(await tx.tryLockSharedScope('push-worker-claim'))) return null
const now = this.now()
// SQLite already serializes the whole transaction.
@@ -134,8 +133,7 @@ export class DurablePushStore {
})
}
// The row lock alone lets a second claimer skip this device's head and lease the row behind it.
// Under the device lock, a fresh read sees every committed lease and every uncommitted head.
// Row locks alone allow leasing behind a skipped device head; device locking plus reread sees committed leases and uncommitted heads.
private async ownsDeviceHead(tx: PushDatabase, row: SqlRow, now: number): Promise<boolean> {
const registrationId = String(row.registration_id)
if (!(await tx.tryLockScope(`push-device:${registrationId}`))) return false
+1 -3
View File
@@ -1,9 +1,7 @@
import { GoogleAuth } from 'google-auth-library'
import { FCM_SCOPE } from './fcm-client.js'
// Resolves the runtime service account credential from the GCE metadata server
// in Cloud Run and from GOOGLE_APPLICATION_CREDENTIALS locally; the library
// caches and refreshes the token itself.
// GoogleAuth handles metadata credentials on Cloud Run and GOOGLE_APPLICATION_CREDENTIALS locally, including cache/refresh.
export function createFcmAccessTokenProvider(): () => Promise<string> {
const auth = new GoogleAuth({ scopes: [FCM_SCOPE] })
return async () => {
@@ -9,8 +9,7 @@ import nacl from 'tweetnacl'
import { decodeCanonicalBase64 } from './canonical-base64.js'
import { deriveHostFingerprint } from './host-fingerprint.js'
// The desktop side of the push challenge, written the way the shipped host
// will answer it, so the gateway is exercised against a real box-opening peer.
// Model shipped desktop challenge answering so gateway tests use a real box-opening peer.
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder()
+1 -3
View File
@@ -27,9 +27,7 @@ export class PushHostSessionStore {
const createdAt = this.now()
const expiresAt = createdAt + PUSH_LIMITS.sessionTtlMs
await this.database.transaction(async (transaction) => {
// Why: a desktop holds one session at a time and only re-proves once it is
// gone, so an earlier row is dead weight. It also bounds the table to one
// row per host however many proofs a self-minted identity answers.
// One active session per desktop bounds rows even for self-minted hosts that flood proofs.
await transaction.lockQuotaScope(`orca-push-session:${hostFingerprint}`)
await transaction.query('DELETE FROM push_sessions WHERE host_fingerprint = ?', [
hostFingerprint
+1 -2
View File
@@ -6,8 +6,7 @@ const CHALLENGE_PRUNE_INTERVAL_MS = 60_000
const SESSION_PRUNE_INTERVAL_MS = 10 * 60_000
const DELIVERY_PRUNE_INTERVAL_MS = 60_000
// Chained rather than periodic, so a sweep spanning many bounded statements never overlaps itself and
// one that exhausted its batch budget resumes at once instead of idling out the rest of the interval.
// Chain sweeps to prevent overlap; budget exhaustion resumes immediately instead of waiting another interval.
function prune(label: string, run: () => Promise<PushPruneSweep>, intervalMs: number): () => void {
let timer: NodeJS.Timeout | undefined
let overdue: NodeJS.Timeout | undefined
@@ -73,8 +73,7 @@ describe('PostgreSQL push gateway startup', () => {
await database.close()
})
// Why: a CREATE INDEX on a grown table can outlive the 5s request deadline,
// and a schema that inherits it fails every startup at the same statement.
// Growing-table CREATE INDEX can exceed request deadlines; inheriting them makes startup fail forever at the same DDL.
it('applies the schema on an untimed pool that is gone before the serving pool opens', async () => {
const database = await openPushDatabase({
databaseUrl: 'postgresql://push@localhost:55440/orca_push',
+7 -17
View File
@@ -55,8 +55,7 @@ class SqliteTransaction implements PushDatabase {
return await operation(this)
}
// BEGIN IMMEDIATE already holds the single writer lock for the whole
// transaction, so there is nothing narrower left to take.
// BEGIN IMMEDIATE already holds SQLite’s single writer lock.
async lockQuotaScope(): Promise<void> {}
async tryLockScope(): Promise<boolean> {
@@ -71,8 +70,7 @@ class SqliteTransaction implements PushDatabase {
}
class SqliteDatabase extends SqliteTransaction {
// node:sqlite is synchronous and has no nested transactions, so overlapping
// callers are serialized behind one tail promise instead of racing BEGIN.
// Serialize callers: synchronous node:sqlite has no nested transactions, so overlapping BEGIN would race.
private tail: Promise<void> = Promise.resolve()
override async query(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
@@ -147,8 +145,7 @@ class PostgresTransaction implements PushDatabase {
function retryablePostgresTransactionError(error: unknown): boolean {
const code = String((error as { code?: unknown }).code)
// 57014 is the pool statement_timeout firing. It aborts the transaction the
// same way a lock timeout does, so it takes the bounded retry path too.
// 57014 statement timeout aborts transactions like lock timeout; use the same bounded retry.
return code === '40P01' || code === '40001' || code === '55P03' || code === '57014'
}
@@ -205,8 +202,7 @@ class PostgresDatabase implements PushDatabase {
throw new Error('postgres_transaction_retry_exhausted')
}
// An advisory transaction lock taken outside a transaction is released by the
// implicit commit before the caller reads anything, which protects nothing.
// Outside an explicit transaction, implicit commit releases the advisory lock before any protected read.
async lockQuotaScope(): Promise<void> {
throw new Error('lock_quota_scope_requires_transaction')
}
@@ -228,11 +224,7 @@ async function applySchema(database: PushDatabase): Promise<void> {
for (const statement of pushSchemaStatements()) await database.query(statement)
}
// Why: DDL is not a request. A CREATE INDEX on a grown table can legitimately
// outlive the request statement_timeout, and inheriting it would fail every
// startup at the same statement instead of finishing once. One connection of
// its own, closed before the serving pool opens, keeps the untimed session off
// the request path entirely.
// DDL may outlast request statement_timeout; apply once on a separate untimed connection, closed before serving, or every startup repeats the failure.
async function applySchemaOnUntimedPool(
databaseUrl: string,
applicationName: string | undefined
@@ -251,8 +243,7 @@ async function applySchemaOnUntimedPool(
try {
await applyPostgresSchema(pushSchemaStatements(), (statement) => database.query(statement), {
eventPrefix: 'orca_push_postgres_schema',
// Push has no catalog pre-check, so a lock timeout here says nothing about whether the
// object already exists and the old bounded retry is still the right answer.
// No catalog pre-check: lock timeout cannot prove DDL already exists, so retain bounded retry.
retryLockTimeout: true
})
} finally {
@@ -262,8 +253,7 @@ async function applySchemaOnUntimedPool(
export function absorbPostgresIdleClientErrors(pool: Pick<pg.Pool, 'on'>): void {
pool.on('error', () => {
// node-postgres removes failed idle clients itself; an unhandled 'error'
// would crash the service and turn a SQL blip into a restart loop.
// Handle idle pg errors after the pool removes failed clients; otherwise transient SQL faults crash-loop the service.
console.warn('[orca-push] idle PostgreSQL client failed')
})
}
+2 -4
View File
@@ -44,8 +44,7 @@ export function readBearer(header: string | undefined): string | null {
return scheme?.toLowerCase() === 'bearer' && token.length > 0 ? token : null
}
// Hono's body limit, not a Content-Length check: a chunked body declares no
// length, and req.json() would buffer all of it before any handler ran.
// Enforce Hono’s body limit: Content-Length misses chunked bodies that req.json would fully buffer.
const limitBody = bodyLimit({
maxSize: PUSH_LIMITS.maxHttpBodyBytes,
onError: (context) => context.json({ error: 'request_too_large' }, 413)
@@ -119,8 +118,7 @@ export function createPushServer(
const app = new Hono<{ Variables: PushVariables }>()
const requestDrain = new PushRequestDrain()
app.use('*', requestDrain.middleware)
// Hono's default handler prints the whole error, and a pg error carries the
// offending row in `detail`. Only the error's name may reach the logs.
// Default Hono logging exposes pg row detail; log only error names.
app.onError((error, context) => {
observability.record('request_error')
console.warn(
@@ -1,3 +1,2 @@
// Twelve drains lift the ~30/s ceiling four drains hit at ~120 ms per item; each drain holds one
// delivery in flight, so this is the worker's concurrency, not its database draw.
// Twelve concurrent deliveries exceed the ~30/s limit of four ~120ms drains; database concurrency is bounded separately.
export const WORKER_DRAINS = 12
@@ -17,8 +17,7 @@ vi.mock('./relay-token-verifier.js', () => ({
import { createRelayApp } from './app.js'
// The message the pool raises when its own dial outruns connectionTimeoutMillis.
// This is the shape that failed a rollout wave as an HTTP 404.
// Actual pg-pool timeout shape that a rollout incorrectly surfaced as 404.
const poolTimeout = () => new Error('Connection terminated due to connection timeout')
function adminRequest(body: unknown): RequestInit {
+10 -33
View File
@@ -97,8 +97,7 @@ const ASSIGNMENT_REJECTION_LOG_WINDOW_MS = 10_000
// A release holds the row for about one lock timeout, so one second is enough.
const ASSIGNMENT_ROW_BUSY_RETRY_AFTER_SECONDS = 1
const REGION_CATALOG_CACHE_MS = 30_000
// A drain that outlives the roll step it belongs to is an outage, not a pacing win. The roll
// step's timeout scales with the window, up to the same-cap wave's slowest pace.
// Drain pace must fit the roll-step timeout, including its slowest same-cap wave.
const DRAIN_PACE_WINDOW_MAX_MS = 20 * 60 * 1_000
type AdmissionRejectionLogEntry = {
@@ -262,9 +261,7 @@ export function createRelayApp(
context.header('Retry-After', String(config.publicAssignmentRetryAfterSeconds))
return context.json({ error: 'assignments_temporarily_unavailable' }, 503)
}
// Reconnecting hosts declare themselves and are verified against the durable
// assignment inside this bounded lane, so a placement backlog can never
// starve session recovery. Unhinted traffic never touches this lane.
// Verify hinted reconnects against durable assignments in a separate bounded lane so placement backlogs cannot starve recovery.
const stickyRetryAfterSeconds = config.publicStickyRetryAfterSeconds ?? 2
const stickyAssignmentAdmission = new RelayPublicAssignmentAdmission({
maxConcurrent: config.publicStickyConcurrency ?? 1,
@@ -310,10 +307,7 @@ export function createRelayApp(
context.header('Retry-After', String(retryAfterSeconds))
return context.json({ error: 'assignments_temporarily_unavailable' }, 503)
}
// An admin route that collapses every failure into one status cannot tell a
// real conflict from a database that was briefly out of reach, and the rollout
// tooling retries on 503 only. Transient failures get the answer the public
// routes already give; everything else keeps the route's own mapping.
// Rollout tooling retries only 503; classify transient admin DB failures like public routes while preserving other route mappings.
const rejectAdminOperation = (
context: Context,
error: unknown,
@@ -327,9 +321,7 @@ export function createRelayApp(
context.header('Retry-After', String(config.publicAssignmentRetryAfterSeconds))
return context.json({ error: 'database_temporarily_unavailable' }, 503)
}
// Aggregate counters cannot separate a handful of pathological hosts from a broad
// population, so every admission rejection names its host and reason. Keyed on
// route:lane:reason rather than host, the log stays bounded under load.
// Log host/reason to distinguish isolated failures; bound windows by route:lane:reason to stay bounded across hosts.
const rejectionLogWindow = new AssignmentRejectionLogWindow<AdmissionRejectionLogEntry>({
windowMs: ASSIGNMENT_REJECTION_LOG_WINDOW_MS,
onWindowClosed: ({ suppressed, sample }) => logAssignmentRejection({ ...sample, suppressed })
@@ -383,9 +375,7 @@ export function createRelayApp(
demote: operations.cellDemote
})
// Not /healthz: Google Front End reserves that path before the container.
// The drain pace cap is read before a roll isolates a cell, so a slower pace than the cell
// accepts stops with the cell untouched.
// Google Front End reserves /healthz. Report drain cap before isolation so an unsupported pace refuses without touching the cell.
app.get('/health', (context) =>
context.json({
ok: true,
@@ -689,10 +679,7 @@ export function createRelayApp(
admission.release()
}
recordSelection(assignment, assignment.placed === true)
// Grant-side counterpart of the rejection log: reconnect grants are rare
// enough to log and make "which cell is this host on" answerable. The
// placement-lane ones matter most — they are the only record that a host
// whose sticky lane failed verification landed anywhere at all.
// Reconnect grant logs locate hosts; placement-lane grants prove where failed sticky verifications eventually landed.
if (body.data.reconnect) {
console.warn(
`[orca-relay] assignment granted lane=${lane} hinted=true` +
@@ -1264,8 +1251,7 @@ export function createRelayApp(
return rejectAdminOperation(context, error, 409)
}
})
// Step 5: the flag workflow's record of a cell's admitMode, which every sweep reads. Without
// `admitMode` it only reads; reserve is refused while a drain, migration or rehome is open.
// Read without admitMode; writes feed every sweep and refuse reserve during open drain/migration/rehome flows.
app.post('/v1/admin/cell-admit-mode', async (context) => {
const bearer = readBearer(context.req.header('authorization'))
if (config.role !== 'director') return context.json({ error: 'director_only' }, 404)
@@ -1856,9 +1842,7 @@ const RegionalRehomeSafetySchema = z
databasePoolWaiting: z.number().int().nonnegative().max(100),
databasePoolWaitersMax: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
databasePoolWaitMsMax: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
// Cells spread the full pool-pressure counts into the payload; rejecting
// the extra gauges 400'd every rehome-status heartbeat since 2026-08-15,
// so no cell ever recorded rehome protocol 1.
// Accept cell pool gauges or strict validation rejects every rehome-status heartbeat, preventing protocol-1 registration.
databasePoolTotal: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
databasePoolIdle: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
databasePoolOldestWaitMs: z
@@ -2071,13 +2055,7 @@ const AdminAdmissionSelectorApplySchema = z
expectedGeneration: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
expectedMembershipSha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
membership: AdmissionSelectorMembershipSchema,
// Optional, so an older caller reaching an updated director is unchanged:
// the cell goes unmarked and its hosts stay pinned, today's behaviour. The
// other direction is NOT ignored — the schema below is .strict(), so an
// updated caller reaching an older director is a 400. That fails closed,
// before the isolate step sets MUTATION_STARTED and before anything is
// written, but it is a deploy ordering constraint: the director ships
// first, then any workflow run that uses the updated script.
// Deploy director before callers: old callers omit this safely, but old strict directors reject it with 400 before isolation/MUTATION_STARTED, leaving hosts pinned.
rollIsolatedCells: z.array(CellIdSchema).max(256).optional()
})
.strict()
@@ -2417,8 +2395,7 @@ function operationError(error: unknown): string {
}
// `suppressed` is present only on a window-closing line, and counts the rejections
// that line stands for beyond the one already logged when the window opened.
// Only window-close logs carry suppressed, excluding the rejection already logged at window open.
function logAssignmentRejection(input: {
route: 'assign' | 'assign-verify' | 'resolve'
lane: AssignmentAdmissionLane | 'none'
@@ -5,8 +5,7 @@ import type { RelayCellConfig } from './config.js'
import { openRelayDatabase, type RelayDatabase } from './database.js'
import { ReserveLedgerWriter } from './reserve-ledger-writer.js'
// A host that lost its control leaves a capped reserve cell this director proves dead (stale
// heartbeat + its own polls unanswered) without an operator fence; every live-cell case keeps it.
// Only host-requested moves may leave a reserve cell proven dead by heartbeat and poll silence without a fence; live cells retain hosts.
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
const describePostgres = databaseUrl ? describe : describe.skip
@@ -273,11 +273,7 @@ describePostgres('PostgreSQL re-placement off a cell isolated for a roll', () =>
await applySelector(Object.fromEntries(CELLS.map((cell) => [cell.id, 'general'])))
}
// The selector is fleet-wide and this database is shared with every other
// Postgres file in the project, all of which write admission through the
// generation-0 helpers. Advancing the generation and leaving it advanced
// would fail every one of them with admission_selector_boundary_active, so
// this file puts the boundary back exactly as it found it.
// Shared PostgreSQL suites use generation-0 helpers; restore the exact selector boundary or later files fail admission_selector_boundary_active.
async function resetSelectorBoundary(): Promise<void> {
await databases[0]!.query(
`UPDATE relay_admission_selectors SET generation = 0, attempt_id = NULL
@@ -329,9 +325,7 @@ describePostgres('PostgreSQL re-placement off a cell isolated for a roll', () =>
afterAll(async () => {
if (databases[0]) {
await deleteHostRows()
// Order matters: drop the cells first, then rebuild the selector's
// membership from what is left, or it names rows that no longer exist and
// every later read fails admission_selector_membership_drift.
// Drop cell rows before rebuilding membership or later reads fail admission_selector_membership_drift.
for (const cell of CELLS) {
for (const table of [
'relay_cell_connection_snapshots',
@@ -497,9 +491,7 @@ describePostgres('PostgreSQL re-placement off a cell isolated for a roll', () =>
)
expect(granted.length).toBeGreaterThan(0)
// However many dials were granted, only one re-placement may have
// committed: the epoch advances by exactly one and the target gains exactly
// one unit. The source keeps its lease until the source releases it.
// Concurrent dials may grant repeatedly but commit one move: one epoch/unit on target, preserving source leases until release.
const settled = await stores[0]!.resolve(identity)
expect(settled?.assignmentEpoch).toBe(first.assignmentEpoch + 1)
expect(settled?.cellId).not.toBe(ISOLATED.id)
@@ -17,14 +17,7 @@ import {
type SqlRow
} from './database.js'
// `migration-only` alone says nothing about a roll: it is the admission class
// (cloud/docs/orca-relay-operations.md:229-233) that evacuation targets, Asia
// `--mode rollback`, a failed wave's re-isolate and newly registered cells all
// occupy durably while holding hosts. The same-cap roll's isolate step is the
// only writer of the roll stamp, via `rollIsolatedCells` on the selector apply
// (cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs isolate mode);
// restore moves the cell to 'general', which clears the stamp in the same
// statement that writes the state.
// migration-only also holds evacuation targets, Asia rollback and failed-wave/new cells; only same-cap isolate writes rollIsolatedCells, and restore clears its stamp atomically (orca-relay-operations.md).
const HEARTBEAT_TTL_MS = 45_000
const START_MS = 100
const IDENTITY = { userId: 'user-a', relayHostId: 'host000000000001' }
@@ -307,9 +300,7 @@ describe('re-placing a host off a cell isolated for a roll', () => {
})
it('keeps a host pinned to a migration-only cell with no roll stamp', async () => {
// The guard for every non-roll flow that parks a loaded cell: an Asia
// `--mode rollback`, an evacuation target awaiting promotion, a failed
// wave's re-isolate. Moving these hosts would undo the operator's intent.
// Unstamped non-roll parking must retain hosts or undo evacuation/Asia rollback/operator intent.
const { store, park, rollIsolatedAt } = await setup()
const first = await store.assign(IDENTITY, 'us-central1')
await park(first.cellId, 'migration-only')
@@ -344,10 +335,7 @@ describe('re-placing a host off a cell isolated for a roll', () => {
{ label: 'just inside the bound', age: 2 * 60 * 60_000 - 1, moves: true },
{ label: 'just outside the bound', age: 2 * 60 * 60_000 + 1, moves: false }
])('treats a stamp $label as $moves', async ({ age, moves }) => {
// Why the bound exists: a roll isolates and restores inside ~15 minutes, so
// an older stamp is a failed wave waiting on an operator, or an orphan left
// by a director rollback whose restore predates the clearing clause. Both
// mean a possibly healthy cell, and the safe answer is the pre-existing one.
// Past a normal ~15-minute roll, stamps may be failed-wave or old-restore orphans; keep possibly healthy hosts pinned for operator recovery.
const { store, heartbeat, isolateForRoll, setNow } = await setup()
const first = await store.assign(IDENTITY, 'us-central1')
await isolateForRoll(first.cellId)
@@ -421,9 +409,7 @@ describe('re-placing a host off a cell isolated for a roll', () => {
})
it('keeps a host pinned on an unstamped cell whose migration has completed', async () => {
// The terminal state of a successful evacuation: the host's row points at
// the target, the migration is completed, and the cell waits migration-only
// for a separate promote dispatch. Re-placing here undoes the evacuation.
// Completed evacuations await separate promotion in migration-only; re-placement would undo them.
const { store, database, park } = await setup()
const first = await store.assign(IDENTITY, 'us-central1')
await insertMigration(database, {
@@ -474,9 +460,7 @@ describe('re-placing a host off a cell isolated for a roll', () => {
})
it('keeps the pin while a migration row is open but its lease has lapsed', async () => {
// Why: the durable relay_assignment_migrations row outlives the 15-minute
// lease the counter tracks, and rollBackStalledRegionalRehomes refuses to
// unwind it while the source is not general. Re-placing would strand it.
// Migration rows outlive their 15-minute counters and cannot roll back from non-general sources; re-placement would strand them.
const { store, database, isolateForRoll } = await setup()
const first = await store.assign(IDENTITY, 'us-central1')
await insertMigration(database, {
@@ -723,9 +707,7 @@ describe('re-placing a host off a cell isolated for a roll', () => {
})
it('emits nothing when the placement rolls back after the decision', async () => {
// Why: the decision and the writes share one transaction. A line already on
// stdout cannot be rolled back with it, so an event written where it is
// decided would have the canary counting moves that never happened.
// Log only committed moves; stdout cannot roll back with a failed lease/reservation transaction.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { store, counter, isolateForRoll } = await setup()
const first = await store.assign(IDENTITY, 'us-central1')
@@ -739,15 +721,13 @@ describe('re-placing a host off a cell isolated for a roll', () => {
)
expect(jsonEvents(warn, 'orca_relay_sticky_replaced_off_isolated_cell')).toEqual([])
// The precondition for reading that absence: the move really was rolled
// back, so the event would have been a lie rather than merely early.
// Prove the move rolled back so the missing event is required, not merely delayed.
expect(await store.resolve(IDENTITY)).toMatchObject({
cellId: first.cellId,
assignmentEpoch: first.assignmentEpoch
})
// Control: the same dial with nothing injected does emit, so the assertion
// above is measuring the rollback and not a broken harness.
// Successful control proves event absence above comes from rollback, not a broken harness.
warn.mockClear()
const moved = await store.assign(IDENTITY, 'us-central1')
expect(moved.cellId).not.toBe(first.cellId)
@@ -5,8 +5,7 @@ import { CELL_ADMIT_EFFECTIVE_FRESH_MS, RelayAssignmentStore } from './assignmen
import type { RelayCellConfig } from './config.js'
import { openRelayDatabase, type RelayDatabase } from './database.js'
// The step-5 census on real PostgreSQL: every new statement (the admit-mode table, the
// open-flow check under FOR UPDATE, the live-reserve pin) must run there, not only on SQLite.
// Exercise admit-mode SQL, locked open-flow checks and live-reserve pins on PostgreSQL, not SQLite alone.
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
const describePostgres = databaseUrl ? describe : describe.skip
@@ -9,9 +9,7 @@ import {
import type { RelayCellConfig } from './config.js'
import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js'
// Production 2026-10-24, production-gce-c15: a same-cap roll isolates the cell, drains it,
// then replaces its one VM. A host that was offline through the drain still names the cell,
// and redialled in the ~45 s before the new process was ready.
// 2026-10-24, production-gce-c15: offline hosts redialed during the ~45s gap between draining a same-cap roll and replacement readiness.
const HEARTBEAT_TTL_MS = 45_000
const START_MS = 100
const IDENTITY = { userId: 'user-a', relayHostId: 'host000000000001' }
@@ -26,11 +26,7 @@ const hosts = ['rowlockhost00001', 'rowlockhost00002', 'rowlockhost00003', 'rowl
(relayHostId) => ({ userId, relayHostId })
)
// Why a deadlock counter and not "it eventually succeeded": the transaction
// wrapper retries 40P01 three times, so a cycle that fires on every wave still
// reports success to the caller while burning the retry budget that turns into
// a 503 under load. PostgreSQL counts every detected cycle in pg_stat_database,
// which sees through the retry.
// pg_stat_database exposes cycles hidden by three 40P01 retries; eventual success alone misses deadlocks that exhaust retries under load.
describePostgres('PostgreSQL row lock order', () => {
const databases: RelayDatabase[] = []
+4 -15
View File
@@ -1512,12 +1512,7 @@ describe('RelayAssignmentStore', () => {
})
it('moves a stranded host off an existing-only cell that stopped serving it', async () => {
// Why: C3's decommission created an existing-only cell that rejects
// attaches; the #194 pin then loops returning hosts forever while each
// grant refreshes their own activity (issue #225). C3 has no
// connection-limits row and expired leases are cleaned within a
// maintenance cycle, so the only durable evidence is the assignment row:
// a recent grant with no live real activity behind it.
// C3 rejected attaches on an existing-only cell; repeated grants defeated dormancy (#194, #225). Use recent failed grants on assignment rows: no cap reservations exist, and expired leases are pruned.
let now = 100
const store = await setup(() => now)
const identity = { userId: 'user-a', relayHostId: 'host000000000001' }
@@ -1562,11 +1557,7 @@ describe('RelayAssignmentStore', () => {
})
it('keeps migration-only cells pinned inside the stranded window', async () => {
// Why: migration-only is an admission class, not a drain signal. Evacuation
// targets, an Asia `--mode rollback` and a failed wave's re-isolate all park
// loaded cells there durably, and moving those hosts would undo the
// evacuation or scatter the region. Only the same-cap roll's isolate stamp
// releases the pin, and nothing here sets it.
// migration-only durably holds evacuation/Asia rollback/failed-wave hosts; only same-cap roll stamps authorize re-placement.
let now = 100
const store = await setup(() => now)
const identity = { userId: 'user-a', relayHostId: 'host000000000001' }
@@ -2648,8 +2639,7 @@ describe('RelayAssignmentStore', () => {
expect(await cellReservations(database!)).toEqual({ 'cell-a': 1, 'cell-b': 0 })
})
// Why: completion needs an enabled target and expiry rollback needs an unregistered one, so a
// target disabled after it registered satisfies neither and the migration never leaves the table.
// Disabled registered targets satisfy neither completion nor expiry rollback, stranding migration rows.
async function wedgeRegisteredMigration(
now: () => number,
cells: RelayCellConfig[],
@@ -2715,8 +2705,7 @@ describe('RelayAssignmentStore', () => {
// A disabled target can never satisfy completion, so waiting cannot help.
expect(await store.completeReadyEvacuations()).toBe(0)
expect(await store.abortExpiredEvacuations()).toBe(1)
// Rollback lands on the disabled source, so the host resolves to nothing and is placed afresh
// by evacuateDeadCells; the point is that the row is retired rather than reaped every tick.
// Rollback to disabled source resolves empty for evacuateDeadCells to re-place; retire the row rather than revisit every tick.
expect(await store.resolve(identity)).toBeNull()
expect(await store.abortExpiredEvacuations()).toBe(0)
})
+67 -270
View File
@@ -173,13 +173,10 @@ export class RelayCellInReserveModeError extends Error {
}
}
// Step 5: the flag workflow records each cell's admitMode here (reserve before the cell's
// switch file, db only after the cell has re-registered its controls). Every per-host sweep
// and reader skips reserve cells, and does nothing while the set is unknown.
// Record reserve before the cell switch, db after control re-registration; per-host sweeps/readers skip reserve and stop while the set is unknown.
const RESERVE_MODE_CACHE_MS = 5_000
const RESERVE_MODE_STALE_MS = 60_000
// After a failed read, the last good one answers this long without a query: during a stall
// every assign would otherwise spend a read's timeout before its own transaction.
// Back off failed reserve-set reads using the last good answer or every stalled assign adds another timeout before BEGIN.
const RESERVE_MODE_FAILURE_BACKOFF_MS = 5_000
// A cell refreshes its admit-effective row every 15 s; three missed refreshes and it counts for nothing.
export const CELL_ADMIT_EFFECTIVE_FRESH_MS = 45_000
@@ -453,31 +450,12 @@ const CELL_RESERVATION_UPDATE = `UPDATE relay_cells SET reserved_requests =
// About one release round trip from Asia, with margin; see assignStickyOnce.
const STICKY_ASSIGNMENT_ROW_WAIT_MS = 1_000
// THE ROW LOCK ORDER. Every transaction that takes more than one of these
// takes them in this order, whichever role it runs on:
//
// 1. relay_assignments (the host's row)
// 2. relay_assignment_migrations
// relay_assignment_activity_leases
// 3. relay_control_connection_reservations (lockControlConnectionReservations)
// 4. relay_cells (lockCellInventory / lockCellRows / the
// conditional reservation UPDATE)
//
// relay_cells is last because it is the only row shared by every host on a
// cell: a transaction that takes it early holds it across every round trip
// that follows, and on a cell far from PostgreSQL that is what turns accepts
// into a queue. Everything above it is per-host, so holding it longer costs
// only that host. Paths that read the inventory to make a placement decision
// cannot defer relay_cells, so they lock the host's rows from tiers 1-3 up
// front instead, before the inventory, and re-check what they read afterwards.
// Tier 1 is what serialises two transactions on the same host; the tiers below
// it keep transactions on *different* hosts from cycling through relay_cells.
// Why: one global FOR UPDATE over a 23-row table serialises every director and
// cell. At the 1s pool lock_timeout each blocked waiter also holds a pooled
// client for a full second, so the queue converts contention into pool
// exhaustion. The lock is held to COMMIT and the assignment path runs many
// statements after taking it, and no hold-time telemetry existed before this
// change, so 500ms is a first value to tune once cellInventoryHoldMsMax lands.
// Lock order across all roles: relay_assignments → relay_assignment_migrations/activity_leases →
// relay_control_connection_reservations → relay_cells (including conditional reservation UPDATE).
// Per-host locks precede the cell shared by all hosts to prevent cross-host deadlocks and shorten
// cell hold time across distant-Postgres round trips. Inventory-first paths acquire tiers 1–3
// before inventory and recheck afterward. Global FOR UPDATE queues also exhaust pooled clients;
// tune the 500ms request wait using cellInventoryHoldMsMax.
export const CELL_INVENTORY_LOCK_TIMEOUT_MS = 500
// The same inventory lock is taken by live requests and by background sweeps,
@@ -487,29 +465,14 @@ export type CellInventoryLockMode =
| 'request'
// Never queue: the caller handles database_lock_unavailable and moves on.
| 'nowait'
// A sweep can enter here, so keep the pool default. Failing sooner would turn
// ordinary contention into a 55P03 the retry wrapper reports as terminal, which
// spends the incident gate's bounded exhausted-retry budget (300 per 5 min).
// Keep pool default for sweeps; an early 55P03 becomes terminal retry exhaustion and spends the incident gate’s 300/5min budget.
| 'pool-default'
// Why: stranded detection (issue #225) needs a grant old enough that a real
// attach would have registered (the 90s activity lease covers dial +
// activation), yet recent enough to prove an active retry loop rather than
// ordinary dormancy — which stays governed by the 24h rule.
// Stranded grants must be old enough to attach (90s lease), yet recent enough to prove a retry loop; ordinary dormancy uses 24h (#225).
const STRANDED_MIN_GRANT_AGE_MS = 60_000
const STRANDED_RECENT_ACTIVITY_MS = 15 * 60_000
// Why: a roll's isolate step writes exactly this state
// (cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs:136) and its
// restore writes 'general' (same line). 'existing-only' is C3's decommission
// posture, where the cell still serves the hosts it already has, so it is
// deliberately not an isolation signal here.
// Same-cap isolate writes migration-only and restore writes general; existing-only still serves incumbents and cannot signal a roll.
const ROLL_ISOLATED_ADMISSION: CellAdmissionState = 'migration-only'
// Why the stamp expires: a roll isolates and restores one cell inside ~15
// minutes, so a stamp older than this is not a roll in progress. It is a failed
// or stalled wave whose failsafe re-isolated a possibly healthy cell and is
// waiting on an operator, or a director rollback whose restore wrote 'general'
// without the clause that clears the stamp, leaving an orphan that the next
// park would reactivate. Both want the same answer, and it is the pre-existing
// one: keep the pin and let the host retry its own cell.
// A stamp older than a ~15-minute roll may be a failed wave or old-restore orphan; keep the possibly healthy pin for operator recovery.
const ROLL_ISOLATION_STAMP_MAX_AGE_MS = 2 * 60 * 60_000
const REGION_PREFERENCE_RETENTION_MS = 30 * 24 * 60 * 60_000
const REGIONAL_REHOME_UNREGISTERED_REFRESH_MS = 5 * 60_000
@@ -579,20 +542,14 @@ class AssignmentCapacityExhausted extends Error {
}
}
// Why a reason of its own: a host whose home cell is fenced-but-unattested is
// refused regardless of fleet headroom, so reporting it as capacity sends
// operators after capacity that was never short. Every cell boot and every
// readiness dip produces these.
// Fenced but unattested home cells refuse regardless of headroom; classify separately or operators chase capacity that was never short.
export type RelayHomeCellUnavailableCause =
| 'draining'
| 'booting'
| 'unheard'
| 'not_ready'
// The host's own row is held, almost always by that host's release on its old
// cell, which sits queued on that cell's busy row. Waiting here would hold the
// director's single sticky slot for the length of that queue, so the dial is
// refused at once and the host redials after the release has settled.
// Own release often holds this host row while queued on the cell; refuse rather than monopolize sticky admission until that queue drains.
export class RelayAssignmentRowBusyError extends Error {
constructor() {
super('relay_assignment_row_busy')
@@ -608,19 +565,11 @@ export class RelayHomeCellUnavailableError extends Error {
}
}
// Debt holds connection headroom for a control that may still arrive shortly
// after its director-side timeout. Nothing legitimately arrives minutes late
// (attach deadline 10s, orphan grace 30s); unretired debt from hosts that
// never return otherwise starves connection headroom fleet-wide and turns
// every placement into relay_capacity_exhausted.
// Retain headroom for shortly late controls (10s attach/30s orphan grace), then retire debt or absent hosts exhaust fleet capacity.
const LATE_ARRIVAL_DEBT_RETENTION_MS = 10 * 60 * 1_000
// A released reservation is read by nothing: every reader filters it out by state, and the only
// statement that still touches it is the per-host lock, which just makes that lock set longer. A day
// is margin for forensics, not for reads.
// Readers ignore released reservations, but host locks still include them; one-day retention is forensic margin.
export const RELEASED_CONTROL_RESERVATION_RETENTION_MS = 24 * 60 * 60 * 1_000
// ~27 rows per page, so a statement deletes a few hundred rows at most. With ~9 director ticks a
// minute the row cap is ~5M rows a day: the 13.7M-row backlog drains over about three days, and a
// walk that finds nothing reads ~1 MB a tick.
// Bound each statement to a few hundred rows; ~9 ticks/min permits ~5M rows/day and empty scans read ~1MB/tick.
const RELEASED_CONTROL_RESERVATION_REAP_BUDGET = {
pagesPerStatement: 16,
maxPagesPerTick: 128,
@@ -663,10 +612,7 @@ export class RelayAssignmentStore {
private readonly regionalRehomeDirectorRegion?: RelayRegion
private readonly requireLiveCells: boolean
private readonly heartbeatTtlMs: number
// Poisoned attempts never complete or abort and stay the oldest rows, so
// unquarantined they eventually fill the sweeps' LIMIT page and starve
// every healthy candidate. Process-local on purpose: it resets on deploy
// and each director relearns within a few ticks.
// Quarantine permanent oldest failures out of LIMIT pages so healthy candidates progress; process-local state relearns within ticks after deploy.
private readonly regionalRehomeCandidateQuarantine = new Map<
string,
{ failures: number; until: number }
@@ -717,10 +663,7 @@ export class RelayAssignmentStore {
this.migrationCellRegistrar = new RelayMigrationCellRegistrar(database, now)
}
// Null when unreadable for a minute: callers then treat every cell as possibly reserve.
// Inside a transaction (any `database` but the pool) it answers from the last read and never
// runs a statement: a failed read there (57014) would abort the transaction, and the next
// statement's 25P02 is not retried. Callers read once before they begin.
// Read before BEGIN: failed in-transaction queries (57014) abort it and lead to unretried 25P02. Transactions use cache only; after a minute unreadable returns null so callers treat all cells as possibly reserve.
async reserveModeCells(
database: Pick<RelayDatabase, 'query'> = this.database
): Promise<ReadonlySet<string> | null> {
@@ -833,8 +776,7 @@ export class RelayAssignmentStore {
return rows.length === 1
}
// A reserve cell still heartbeating holds its hosts in memory, ready or not (a database
// brownout drops ready): the same test as break glass, so neither re-places open sockets.
// Heartbeating reserve cells retain in-memory hosts even when DB brownouts clear ready; use break-glass liveness to avoid duplicate placement.
private async liveReserveCell(
cellId: string,
database: RelayDatabase,
@@ -1007,8 +949,7 @@ export class RelayAssignmentStore {
identity: AssignmentIdentity,
preferredRegion?: RelayRegion,
placementRegion: RelayRegion = preferredRegion ?? RELAY_DEFAULT_REGION,
// evacuateDeadCells re-enters placement from a sweep; it must not take the
// bounded wait, whose 55P03 would surface as a terminal sweep failure.
// Sweep placement keeps pool default; a shorter bounded wait would surface 55P03 as terminal sweep failure.
lockMode: CellInventoryLockMode = 'request',
// Step 5: a fresh placement may first be booked on a reserve-mode cell, outside any
// transaction; and a database mint stays above every epoch the director's map knew.
@@ -1049,8 +990,7 @@ export class RelayAssignmentStore {
}
const booked = step5.placeFresh ? await bookReserve(step5.placeFresh) : null
if (booked) return booked
// Only placement needs the global inventory critical section; queueing those
// attempts locally avoids turning true placement bursts into NOWAIT storms.
// Serialize placement’s global inventory section locally so bursts do not become NOWAIT storms.
const placeOnDatabase = async (keepRegion: boolean) =>
await this.serializeAssignment(
async () =>
@@ -1178,10 +1118,7 @@ export class RelayAssignmentStore {
const now = this.now()
await this.reserveModeCells()
return await this.database.transaction(async (transaction) => {
// Why: the retry exists to take a cell row before the assignment row, the
// order placement uses. It only ever needs the one cell this host is
// pinned to, so read the pin unlocked and lock that row alone; taking all
// 23 queued every sticky refresh in the fleet behind every other one.
// Legacy-order retry locks only the unlocked pin’s cell before assignment; locking all cells would serialize fleet-wide sticky refreshes.
const pinnedCellId = await this.pinnedCellId(transaction, identity)
const lockedCells =
!inventoryFirst || pinnedCellId === undefined
@@ -1193,12 +1130,7 @@ export class RelayAssignmentStore {
pinnedCellId === undefined
? undefined
: await this.pinnedCellAdmission(transaction, pinnedCellId)
// Why wait at all: a calm host redialling after its own clean close meets
// its own release, and a refusal costs it the client's 5 s assign gate. On
// a live general cell that release is short, so a bounded wait is cheap.
// A roll, drain or dead cell keeps its release queued, so refuse at once.
// Never while holding the cell row: that reverses the lock order against
// this host's own release, which holds its row and wants the cell's.
// Wait briefly only for live general-cell releases to avoid the client’s 5s redial gate; never wait while holding cell first, which cycles against host-first release.
const pinIsLiveGeneral =
lockedCells === undefined &&
pinnedCellId !== undefined &&
@@ -1238,12 +1170,7 @@ export class RelayAssignmentStore {
return null
}
// Why: a null here means "fall through to placement", which is exactly
// what an isolated incumbent needs — and the only way out, because
// re-granting the pin refreshes the host's own activity and sustains the
// loop. The placement lane re-places it on a cell that will take it.
// Decided before the pinned cell row is touched: during a drain that row
// is the one the cell's releases keep busy.
// Fall through before touching the busy draining cell; re-granting its pin refreshes activity and sustains the retry loop.
if (
await this.incumbentCellIsolatedForRoll(
transaction,
@@ -1334,18 +1261,7 @@ export class RelayAssignmentStore {
})
}
// Why: PR #194 pins assignments on existing-only cells so capacity pressure
// cannot scatter existing hosts — assuming those cells still serve them. A
// decommissioning cell (C3) broke that assumption: existing-only AND
// rejecting attaches, so pinned hosts loop forever while each grant
// refreshes their own last_activity_at, keeping dormancy-based
// reassignment permanently out of reach (issue #225). "Stranded" therefore
// requires proof the cell is not serving this host: a recent grant
// (last_activity_at inside the window) that had ample time to attach and
// still produced no live real activity. The evidence must come from the
// assignment row itself — reservation rows do not exist for cells without
// connection limits (C3), and expired pending leases are cleaned within a
// maintenance cycle, so neither reliably survives until the next grant.
// Existing-only pins (#194) assume attaches still work; C3 decommission broke this (#225). Prove stranding from a recent grant old enough to attach but with no live activity: reservation rows may not exist and expired leases are pruned.
private async assignmentStrandedOnUnservedCell(
transaction: RelayDatabase,
identity: AssignmentIdentity,
@@ -1413,13 +1329,7 @@ export class RelayAssignmentStore {
let retryScope: RetriedAssignmentInventoryScope =
inventoryScope === 'none' ? 'general' : inventoryScope
await this.reserveModeCells()
// Why the events ride back out rather than being written where they are
// decided: everything below runs in one transaction, and a reservation or
// lease write that fails after the decision rolls the placement back. A
// line already on stdout cannot be rolled back with it, so the canary would
// count re-placements that never happened. Returning them means only a
// committed attempt emits, and a transaction retry cannot leave a stale
// line behind either.
// Return events out of the transaction; stdout cannot roll back failed/retried placements and would inflate canary move counts.
const outcome = await this.database.transaction(async (transaction) => {
const events: string[] = []
// The retry paths below open with the inventory, so this path takes its
@@ -1445,10 +1355,7 @@ export class RelayAssignmentStore {
)
: undefined
const existing = await this.assignmentRowOrBusy(transaction, identity)
// A dormant host holds no units, so its placement never writes its old
// cell and needs only the rows it could land on.
// A live reserve cell's hosts hold no activity rows here: idle there is not dormant. A
// dead one holds no duplicate, so its hosts are re-placed as any dormant host is.
// Dormant hosts hold no units and need only target locks. Live reserve cells keep activity in memory, so empty rows prove dormancy only once dead.
const dormant =
!existing ||
(mayNormallyReassign(activity(existing), now) &&
@@ -1469,10 +1376,7 @@ export class RelayAssignmentStore {
let isolatedIncumbent: CellRow | undefined
let isolatedTarget: CellRow | undefined
let deadReserveSource: { cell: CellRow; verdict: DeadCellVerdict } | undefined
// Set when only the target rows are locked. The old cell's row is never
// written then, so the host must hold no units that no lease backs.
// Counters below the leases (drift the old reset left) are fine: the
// write below rebuilds them from the leases.
// Skipping source lock requires no units unbacked by leases; lower drifted counters are rebuilt from retained leases below.
let sourceRowSkipped = false
if (existing && !dormant && inventoryScope !== 'all') {
// Unlocked on purpose: this only chooses which rows to lock. A restore
@@ -1519,9 +1423,7 @@ export class RelayAssignmentStore {
currentRow,
await this.cellRegion(transaction, text(existing, 'cell_id'))
)
// Why: an existing-only cell that rejects attaches (C3's decommission
// posture) must not re-pin the hosts it refuses; the dead-cell fence
// path below does not apply either — the cell is live, just unwilling.
// Live existing-only cells rejecting attaches cannot re-pin hosts; dead-cell fencing does not apply to this C3 posture.
strandedReassignment = await this.assignmentStrandedOnUnservedCell(
transaction,
identity,
@@ -1532,12 +1434,7 @@ export class RelayAssignmentStore {
strandedReassignment ||
!this.requireLiveCells ||
(await this.cellIsLive(transaction, current.cellId, now))
// Why: the sticky lane sends an isolated incumbent here, and the gate
// below would hand the pin straight back — the cell is live and, being
// emptied, has more headroom than anyone. A cell that is *not* live is
// left to the dead-cell path below, whose fence is the only proof that
// an unreachable cell has stopped serving the sockets it still holds —
// unless a process started since the roll stamp proves the drained one gone.
// Drain headroom must not retain an isolated pin; unreachable cells need fencing unless a post-roll restart proves old sockets gone.
if (
!strandedReassignment &&
(await this.incumbentCellIsolatedForRoll(
@@ -1626,13 +1523,8 @@ export class RelayAssignmentStore {
}
connectionHeadroomReassignment = true
}
// Why no fence for a live isolated cell: a fence proves a cell we cannot
// contact has stopped serving a host. This one is contactable and
// enforces the invariant itself — verifyCellAssignment reads
// relay_assignments live, so the epoch bump below makes it close any
// attach naming the old epoch with 4409. `isolatedIncumbent` is only set
// when the cell is live or restarted since the stamp, so a cell that
// stopped heartbeating while still holding sockets still takes this branch.
// Live isolated cells verify epochs (4409); a post-roll process restart proves the old sockets gone.
// Without either proof, the dead-cell path still requires fencing.
if (!strandedReassignment && !connectionHeadroomReassignment && !isolatedIncumbent) {
const fence = await this.deadCellFenceRequirement(
transaction,
@@ -1708,10 +1600,7 @@ export class RelayAssignmentStore {
})
)
}
// Units come off the cell that holds them. A lease this placement deletes
// frees its units on its own cell; a lease it keeps stays counted until
// its own release or expiry. Only units no lease backs are charged to
// the old cell, which is locked whenever there are any.
// Freed leases charge their own cells; retained leases count until release/expiry. Only unbacked units charge the old cell, whose row must then be locked.
const deletesLeases = forcedDeadReassignment || strandedReassignment
const keptLeases = deletesLeases ? [] : activityLeases
if (existing) {
@@ -1720,10 +1609,7 @@ export class RelayAssignmentStore {
await this.adjustCellReservation(transaction, text(existing, 'cell_id'), -unbackedUnits)
}
if (deletesLeases) {
// A fenced/dead incarnation cannot own drainable work, and a
// stranded host's only leases are the unclaimed grant artifacts of
// its own loop. Removing them prevents late expiry from
// decrementing the replacement.
// Dead/fenced incarnations have no drainable work; stranded leases are unclaimed loop artifacts. Delete them so late expiry cannot decrement replacements.
for (const [cellId, units] of leaseUnitsByCell(activityLeases)) {
await this.adjustCellReservation(transaction, cellId, -units)
}
@@ -3434,8 +3320,7 @@ export class RelayAssignmentStore {
const cellIds = (await this.deadCellEvacuationCandidates(cutoff)).filter(
(cellId) => !reserveCells.has(cellId)
)
// Why: without a candidate cell the host query below walks every assignment by primary key to
// return nothing (574 ms per call in production, from stale existing-only cells it can never act on).
// Without candidates, this scans all assignments for nothing (~574ms in production on stale existing-only cells).
if (cellIds.length === 0) return 0
const rows = await this.database.query(
`SELECT assignment.user_id, assignment.relay_host_id, assignment.cell_id
@@ -4158,9 +4043,7 @@ export class RelayAssignmentStore {
}
const units = ACTIVITY_REQUEST_UNITS[input.kind]
if (existing) {
// Why: a client-chosen activity id can move between cells, so lock the
// one or two rows this path touches in cell_id order, the same order
// placement takes the inventory in, and no cycle can form.
// Client activity IDs can change cells; lock both touched rows in inventory’s cell_id order to prevent cycles.
await this.lockCellRows(transaction, [text(existing, 'cell_id'), input.cellId])
await this.removeActivityLease(transaction, identity, existing, now)
await this.adjustCellReservationAtomically(transaction, input.cellId, units)
@@ -4237,10 +4120,7 @@ export class RelayAssignmentStore {
if (!control || Number(control.enabled) !== 1 || Number(control.not_before) > now) {
return gated('control-closed')
}
// The dispatch budget is durable and global, but until now only
// `commitIdleRegionalRehome` consulted it -- after the join had already run and
// the worker had already POSTed every candidate to its source cell. An absent
// row means the budget has never been spent, so it opens the gate.
// Check durable global dispatch budget before joining/POSTing candidates; absent worker state means no budget spent yet.
const worker = (await this.database.query(
`SELECT paused_until, next_dispatch_at FROM relay_region_rehome_worker_state
WHERE worker_id = 'global'`
@@ -4307,18 +4187,13 @@ export class RelayAssignmentStore {
// either way, so no later candidate in this poll can get past it.
let safetyPaused = false
let targetDeferral: RegionalRehomeTargetDeferral | null = null
// Lock order: control, worker, host rows, then the target cell row alone,
// as the last statement. No fleet-wide lock: from a cell far from Postgres
// each statement costs a cross-region round trip, and the old inventory
// lock held every relay_cells row across ~20 of them.
// Lock control→worker→host→one target cell, last; fleet-wide locking previously held all cells across ~20 cross-region round trips.
const result = await this.database.transaction(async (transaction): Promise<IdleRegionalRehomeCommit> => {
safetyDisable = null
safetyPaused = false
targetDeferral = null
const now = this.now()
// NOWAIT: these rows serialise the rate budget, the open-migration count
// and the hard-cap read, and a second commit must not queue behind one
// that is crossing the ocean.
// NOWAIT keeps global rate/migration/hard-cap budgets from queueing behind cross-region commits.
const control = (await transaction.queryLocked(
`SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'`,
[],
@@ -4545,9 +4420,7 @@ export class RelayAssignmentStore {
for (const [position, row] of ordered.entries()) outcomes[row.index] = parsed[position]!
return outcomes
} catch (error) {
// One statement means one contended assignment row can fail the whole
// batch, so a failure degrades to the per-host statements this replaced
// rather than costing every other host on the cell its renewal.
// One contended host can fail the batch; fall back to per-host writes so other leases still renew.
console.warn(
JSON.stringify({
event: 'orca_relay_control_renewal_batch_failed',
@@ -4555,8 +4428,7 @@ export class RelayAssignmentStore {
message: String((error as { message?: unknown }).message)
})
)
// No connection at all, or a lost reply: per-host statements would only queue up
// behind the same saturated pool or stalled server, multiplying the load.
// Skip fallback on connection loss/read timeout; per-host retries multiply load on the same stalled pool/server.
if (isPostgresPoolConnectFailure(error) || isPostgresReadTimeout(error)) {
return outcomes.fill('database_error')
}
@@ -4727,9 +4599,7 @@ export class RelayAssignmentStore {
return (await this.activateControlOnce(identity, input, false)).activityId
}
// Step 5 flip back: the same activation, but the cell-row delta is returned for the caller
// to fold into one relay_cells write per batch (commitCellReservationDelta), not committed
// here. A dozen re-registrations from Asia each holding that row's lock convoy on it.
// Flip-back returns deltas for one commitCellReservationDelta write per batch; separate cross-region re-registrations would convoy on the cell row.
async activateControlDeferringCell(
identity: AssignmentIdentity,
input: ControlActivationInput
@@ -4861,10 +4731,7 @@ export class RelayAssignmentStore {
input.idleRegionalRehome && input.cellIncarnation ? 1 : 0
]
)
// Last, and only if the count actually moved: the cell row is shared by
// every host on the cell, and this transaction spans a dozen round
// trips. Holding its write lock from the first of them capped a
// far-from-Postgres cell at a couple of accepts a second.
// Write shared cell row only last and only for nonzero delta; holding it across a dozen remote round trips capped accepts at a few per second.
if (reservationDelta !== 0 && !deferCell) {
await this.commitCellReservationAtomically(
transaction,
@@ -6321,9 +6188,7 @@ export class RelayAssignmentStore {
return await this.readRegionalRehomeFleetSafety(this.database, this.now())
}
// The rehome fleet is every general cell that can be drained: those are the
// sources and, because a host must be movable back out again, the only legal
// targets. The region join stays so a cell with no region row is excluded.
// Both source and target must be general/drainable so hosts can move back out; retain region join to exclude unassigned cells.
private async readRegionalRehomeFleetSafety(
database: RelayDatabase,
now: number
@@ -6787,17 +6652,7 @@ export class RelayAssignmentStore {
}
}
// The rehome commit's only relay_cells lock: one row, NOWAIT, as the final
// statement. NOWAIT because the commit took the host's assignment row first,
// the reverse of placement's order; a wait here could deadlock with it.
// Enabled, admission and capacity are checked against the locked rows inside
// the statement, because an admission flip no longer serialises against this
// transaction any other way. Locking the admission row too makes a flip that
// committed after the statement's snapshot re-evaluate on its new version.
// Connection headroom is re-checked here too: the candidate read happened before this
// transaction's own reservation insert, and a placement may have reserved since. Every
// other reservation inserter holds this row first, so NOWAIT defers rather than races.
// The host's own reservation is excluded by key because the insert may have added none.
// Last statement locks one target row NOWAIT: host-first ordering must not wait against placement. Recheck enabled/admission/capacity and headroom under locks, including admission flips since snapshot; exclude own reservation by key because insert may add none.
private async reserveRegionalRehomeTargetRow(
database: RelayDatabase,
input: {
@@ -6894,9 +6749,7 @@ export class RelayAssignmentStore {
RETURNING generation`,
[now]
)
// The durable disable is otherwise invisible: nothing else records why
// claims stopped and inspection only shows enabled=false. Logged after
// the transaction commits so a rollback cannot fabricate the record.
// Return disable reason for logging after commit; inspection alone shows only enabled=false, and rolled-back decisions must not log.
if (disabled.length === 0) return null
return {
event: 'orca_relay_regional_rehome_safety_disabled',
@@ -7026,9 +6879,7 @@ export class RelayAssignmentStore {
return completed
}
// Repeated invariant failures quarantine the attempt out of the sweeps'
// LIMIT pages: poisoned rows are permanent and always the oldest, so
// without exclusion they eventually starve every healthy candidate.
// Quarantine repeated permanent failures or their oldest rows fill LIMIT pages and starve healthy candidates.
private recordRegionalRehomeCandidateFailure(
operation: 'complete' | 'abort' | 'refresh',
attemptId: string,
@@ -7312,11 +7163,7 @@ export class RelayAssignmentStore {
})
}
// Why: counter skew left by a pre-fix sticky grant is reconstructible from the
// locked lease rows; lease shape and migration topology are not, so only a
// count mismatch is repaired and the re-assert still throws on anything else.
// reconcileReservationAccounting cannot stand in: it opens its own
// transaction, while this has to run inside the sweep's on rows it holds.
// Repair only counter skew from locked leases; shape/topology failures must still throw. reconcileReservationAccounting opens its own transaction, so cannot replace this in-sweep repair.
private async repairAssignmentActivityCounts(
transaction: RelayDatabase,
identity: AssignmentIdentity,
@@ -7393,11 +7240,7 @@ export class RelayAssignmentStore {
return integer(completed[0]!, 'changes') + integer(aborted[0]!, 'changes')
}
// A move the host never finished: it holds no activity on the source and is
// not present at the target, so the registered migration row can do nothing
// but occupy one of the eight concurrent slots until something clears it.
// Rolling it back leaves the durable assignment on the source, so the host
// lands where it started whenever it next reconnects.
// Unarrived hosts with no source activity waste one of eight migration slots; rollback leaves their original durable source for next reconnect.
async abortUnarrivedRegionalRehomes(limit = 100): Promise<number> {
return await this.rollBackStalledRegionalRehomes({
sweep: 'abort-unarrived-regional-rehomes',
@@ -7408,9 +7251,7 @@ export class RelayAssignmentStore {
})
}
// The last-resort latch, and the only sweep that disables the switch. With
// the arrival sweep above running it should never reach a row; one that
// survives a day past dispatch means the rollback path itself is broken.
// Only this last-resort sweep disables rehoming: surviving a day past dispatch means normal arrival rollback failed.
async abortExpiredRegionalRehomes(limit = 100): Promise<number> {
return await this.rollBackStalledRegionalRehomes({
sweep: 'abort-expired-regional-rehomes',
@@ -7551,9 +7392,7 @@ export class RelayAssignmentStore {
WHERE attempt_id = ?`,
[now, input.abortReason, now, text(attempt, 'attempt_id')]
)
// Only the last-resort latch turns the feature off. A host that closed
// its laptop mid-move says nothing about whether rehoming is safe, and
// one such row a day would otherwise disable the switch every day.
// Only last-resort expiry disables the feature; a laptop closing mid-move is not evidence rehoming is unsafe.
if (input.disableControl) {
await transaction.query(
`UPDATE relay_region_rehome_control
@@ -7876,10 +7715,7 @@ export class RelayAssignmentStore {
userId: text(candidate, 'user_id'),
relayHostId: text(candidate, 'relay_host_id')
}
// Why: re-check under the canonical assignment-first lock so a concurrent
// renewal wins without cleanup reaping its refreshed lease.
// Several directors sweep the same expired rows. Skip a row another
// director is settling instead of waiting and retrying the transaction.
// Recheck under assignment-first locks so concurrent renewals win; multiple directors skip busy rows instead of reaping refreshed leases or retrying contention.
await this.assignmentRow(transaction, identity, true)
const activityLeases = await this.lockAssignmentActivities(transaction, identity, true)
const lease = activityLeaseById(activityLeases, text(candidate, 'activity_id'))
@@ -8013,9 +7849,7 @@ export class RelayAssignmentStore {
targetCellId
]
)
// Only the two cells this repairs need holding. The id set below is an
// existence check against a table that only reconcileCells writes, so it
// reads unlocked instead of dragging the other 21 rows into the section.
// Lock only the two repaired cells; this existence check reads the reconcileCells-owned table unlocked instead of holding the fleet.
const cellIds = new Set(
(await transaction.query(`SELECT cell_id FROM relay_cells`)).map((row) =>
text(row, 'cell_id')
@@ -8134,13 +7968,7 @@ export class RelayAssignmentStore {
return rows
}
// Per-connection paths touch one or two cells. Locking exactly those rows,
// in the same ascending order the inventory lock uses (ORDER BY fixes the
// row-lock order), keeps them off the fleet-wide lock without a cycle.
// The wait policy follows the caller for the same reason the inventory lock's
// does: a sweep must not fail terminally on ordinary contention. Hold time is
// sampled only for a caller that names its site: short single-row holds under
// the inventory label would flatter the fleet-wide lock they replace.
// Lock touched cells in inventory’s ascending order to avoid cycles. Honor caller wait policy so sweeps tolerate contention; sample only named sites so short holds cannot flatter inventory metrics.
private async lockCellRows(
database: RelayDatabase,
cellIds: string[],
@@ -8157,10 +7985,7 @@ export class RelayAssignmentStore {
)
}
// Tier 3 of the row lock order: a path that will take relay_cells and also
// touch this host's reservations takes them here, before the cell rows. The
// set is the host's own rows, so it is small and known before any placement
// decision is read.
// Lock this host’s reservations before any cell row (tier 3); the set is known before placement selection.
private async lockControlConnectionReservations(
database: RelayDatabase,
identity: AssignmentIdentity,
@@ -8283,9 +8108,7 @@ export class RelayAssignmentStore {
// and an optional parameter left a second fleet-wide lock reachable here.
rows: SqlRow[],
preferredRegion: RelayRegion,
// Ordinary placement treats region as a preference and spills globally
// rather than refuse a host a cell. Re-placing off an isolated cell asks for
// its own region first, and leaves it only in a separate second tier.
// Ordinary placement spills globally; isolated incumbents exhaust their own region before a separate cross-region tier.
regionMode: 'prefer' | 'require' = 'prefer'
): Promise<CellRow | null> {
const regions = new Map(
@@ -8579,22 +8402,12 @@ export class RelayAssignmentStore {
)
}
// Why: a cell isolated for a roll keeps heartbeating ready=1 for the whole
// drain while its registry refuses every attach with 4503, so `cellIsLive`
// cannot see that the host's home has stopped taking it. Re-granting the pin
// is what turns a roll into 13-16 minutes of retry loop; returning true here
// sends the host down the ordinary placement lane on its next dial instead.
//
// The stamp, not the admission state, is the signal. 'migration-only' is an
// admission class that evacuation targets, Asia `--mode rollback`, a failed
// wave's re-isolate and newly registered cells all occupy durably while
// holding hosts; moving those would undo an evacuation or scatter a region.
// Roll-isolated cells heartbeat ready but reject attaches with 4503, so cellIsLive misses retry loops. Require a roll stamp: migration-only also durably holds evacuation/Asia rollback/failed-wave/new-cell hosts that must stay pinned.
private async incumbentCellIsolatedForRoll(
database: RelayDatabase,
identity: AssignmentIdentity,
existing: SqlRow,
// The caller's clock, not a second this.now(): one assign reasons about one
// instant, and the stamp's age decides whether a host moves.
// Use caller’s instant consistently; stamp age decides host movement.
now: number,
// Placement has already read the whole table; sticky has not, and a single
// pinned cell does not justify a second fleet-wide read.
@@ -8610,9 +8423,7 @@ export class RelayAssignmentStore {
const pinned = pinnedAdmission ?? (await this.pinnedCellAdmission(database, cellId))
if (!rollIsolationIsCurrent(pinned, now)) return false
if (integer(existing, 'migration_leases') > 0) return false
// A migration owns this assignment's epoch on both sides, and its durable
// row outlives the 15-minute lease that the counter above tracks, so the
// counter alone would re-place a host out from under a stalled migration.
// Durable migration owns both epochs beyond the 15-minute counter lease; counter-only checks could move a stalled migration’s host.
const open = (
await database.query(
`SELECT COUNT(*) AS open FROM relay_assignment_migrations
@@ -8636,9 +8447,7 @@ export class RelayAssignmentStore {
[cellId]
)
)[0]
// Unknown, missing or unrecognised admission fails safe: the pin stays. This
// reader must not throw — it now sits on the sticky path every dial takes,
// and the rule it feeds is "move the host", so silence has to mean "don't".
// Unknown admission must retain the pin; never throw from the per-dial sticky reader that authorizes moves.
if (!row || !isCellAdmissionState(row['admission_state'])) return undefined
return {
state: row['admission_state'],
@@ -8984,9 +8793,7 @@ export class RelayAssignmentStore {
activityId
]
)
// A released row's claim_activity_id is history, not a live claim: a control that
// reconnects under the same generation would otherwise leave its fresh reservation
// unclaimable, decaying through debt while the connection is already enforced.
// Released claim IDs are history; treating them as live leaves same-generation reconnect reservations unclaimable, decaying into debt while already enforced.
const alreadyClaimed = await database.queryLocked(
`SELECT reservation_id FROM relay_control_connection_reservations
WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?
@@ -9121,9 +8928,7 @@ export class RelayAssignmentStore {
await this.adjustActivityCount(database, identity, kind, -1, now, now)
}
// Returns the request units this removal frees on `cellId`. The caller folds
// them into the one conditional cell-row write it makes just before COMMIT,
// so no statement here touches the fleet's most contended row.
// Return freed units for the caller’s final conditional cell write; do not touch that contended row here.
private async removeSupersededSameCellControls(
database: RelayDatabase,
identity: AssignmentIdentity,
@@ -9232,8 +9037,7 @@ export class RelayAssignmentStore {
if (rows.length === 0) await this.refuseCellReservation(database, cellId)
}
// The same write as the last statement of its transaction, committed in the same round
// trip so the shared cell row is not held while the reply crosses to a far cell.
// Commit with the final cell write in one round trip or its lock lasts across the remote reply.
private async commitCellReservationAtomically(
transaction: RelayDatabase,
cellId: string,
@@ -9309,9 +9113,7 @@ function activityLeaseById(rows: SqlRow[], activityId: string): SqlRow | undefin
return rows.find((row) => text(row, 'activity_id') === activityId)
}
// Why: mid-rehome a host legitimately holds the source control plus the
// target's pending control, so only the lease rows — never the counter a grant
// would overwrite — can say whether this cell is already reserved for it.
// Mid-rehome source and pending target controls coexist; only leases prove existing reservations, since grants overwrite counters.
function holdsControlLease(
rows: SqlRow[],
cellId: string,
@@ -9599,10 +9401,7 @@ function cellInventoryLockOptions(mode: CellInventoryLockMode): RelayLockOptions
return { lockTimeoutMs: CELL_INVENTORY_LOCK_TIMEOUT_MS, measureHoldMs: true }
}
// Background sweeps take the cell inventory NOWAIT so they never queue ahead of
// assignment traffic. A skipped candidate is re-derived from durable state on
// the next tick, so it is ordinary contention, not a sweep failure: one summary
// line per tick, never an error and never a quarantine.
// NOWAIT skips re-derive from durable state next tick; log one contention summary, never sweep failure or quarantine.
function warnSweepCellInventoryBusy(sweep: string, skipped: number): void {
if (skipped === 0) return
console.warn(
@@ -9793,9 +9592,7 @@ function cellUncleanSkip(
}
}
// Candidate skips are otherwise invisible: they neither latch the control off
// nor produce attempts, so an operator cannot tell "skipping" from "idle".
// Cell ids and counters only — never free-form error text.
// Log skips to distinguish them from idle; only cell IDs/counters, never free-form errors.
function regionalRehomeCellSafetyIsClean(
safety: SqlRow | undefined,
@@ -1,13 +1,6 @@
import { runRelayBackgroundOperation } from '../relay-background-operation.js'
// The twelve periodic assignment sweeps the director runs every 30s. Each step
// re-derives its state from the database and is idempotent, so they carry no
// intra-tick ordering dependency — which is what makes per-step isolation
// sound: one failing sweep costs one tick of itself, never the other eleven.
// (A single poisoned rehome row once silenced the whole chained form
// fleet-wide.) Sweep failures are logged, never fed into the rehome worker's
// dispatch-failure budget: a sweep exception is not a dispatch failure and
// must not durably disable regional rehoming.
// Each 30s sweep is idempotent and re-derives DB state, so isolate failures per step. Log sweep errors without spending dispatch-failure budget or disabling rehoming.
export type AssignmentCleanupStore = {
refreshRegionalRehomeLeases(): Promise<unknown>
completeReadyEvacuations(): Promise<unknown>
@@ -1,9 +1,7 @@
import { percentile } from '../relay-observability.js'
import { classifyAssignmentLease, type AssignmentLeaseClass } from './assignment-lease.js'
// What `ticketCheck=enforce` would have said, beside the database answer that still decides.
// `agree`/`disagree` replace `valid`: a valid lease the database refused is the case step 5
// has to explain before a lease alone may admit a host.
// Compare enforce’s hypothetical lease verdict with the authoritative DB answer; agree/disagree exposes valid leases the DB refuses before lease-only admission.
export type AssignmentLeaseShadowClass = Exclude<AssignmentLeaseClass, 'valid'> | 'agree' | 'disagree'
export const ASSIGNMENT_LEASE_SHADOW_EVENT = 'orca_relay_assignment_lease_shadow'
@@ -1,10 +1,6 @@
type CancelWindow = () => void
// Admission rejections run at ~17/s per director instance, so the log summarizes
// instead of streaming: one line when a key's window opens, then a closing line
// carrying whatever that same window suppressed. The closing line is driven by the
// window's own timer, so a key that falls quiet still reports its final count
// instead of waiting for a rejection that may never arrive.
// At ~17 rejections/s, log window open and timer-driven close with suppressed count; quiet keys still report their final totals.
const MAX_TRACKED_KEYS = 64
type RejectionWindow<TSample> = {
+2 -9
View File
@@ -6,20 +6,13 @@ import {
} from './postgres-pool-pressure.js'
import { postgresErrorCodeCategory } from './postgres-query-failure.js'
// Only a failure to reach Postgres at all. A retry here re-runs the schema
// apply, and applyPostgresSchema refuses to repeat a DDL lock timeout on
// purpose: relation locks are granted in queue order, so a repeat parks every
// writer behind the same statement again. 55P03, 57014 and 53300 therefore stay
// terminal at boot even though the request path calls them transient.
// Retry only unreachable PostgreSQL. Reapplying DDL after lock/statement/capacity errors (55P03/57014/53300) requeues ahead of writers, so these remain terminal at boot.
function isBootDatabaseUnreachable(error: unknown): boolean {
const code = postgresErrorCodeCategory(error)
return isPostgresPoolConnectFailure(error) || code === '08001' || code === '08006'
}
// A cell boots beside a cloud-sql-proxy that is itself still dialling, so the
// first pool acquire can outrun the 2s connect timeout that protects the
// request path. The window is longer than a proxy cold start and shorter than
// the restart loop it replaces.
// Proxy cold starts can exceed the request pool’s 2s connect timeout; retry longer than cold start but shorter than the restart loop.
const BOOT_OPEN_RETRY = {
attempts: 20,
windowMs: 45_000,
@@ -38,12 +38,7 @@ type ApplySelectorInput = {
expectedGeneration: number
expectedMembershipSha256?: string
membership: CellAdmissionMembership
// Why a marker rather than reading 'migration-only' directly: that state is an
// admission class, not a drain signal (orca-relay-operations.md:229-233).
// Evacuation targets, Asia `--mode rollback`, a failed wave's re-isolate and
// newly registered cells all sit there durably while holding hosts. Only the
// same-cap isolate step names cells here; every write out of 'migration-only'
// clears the stamp, so a restore cannot leave one behind.
// migration-only also parks evacuation/Asia rollback/failed-wave/new cells (orca-relay-operations.md); only same-cap isolate writes stamps, cleared on every exit from that state.
rollIsolatedCells?: string[]
}
@@ -201,10 +196,7 @@ export class RelayCellAdmissionSelector {
const rollIsolated = new Set(input.rollIsolatedCells ?? [])
for (const [state, cellIds] of membershipEntries(membership)) {
for (const cellId of cellIds) {
// Three-way, in the same statement as the state so they cannot tear:
// stamp a named isolate (keeping an earlier stamp, so a failed wave's
// re-isolate stays marked), clear on any move out of 'migration-only',
// and leave an unnamed 'migration-only' cell exactly as it was.
// Update stamp atomically with state: named isolates preserve prior stamp, leaving migration-only clears it, unnamed migration-only preserves it.
const marker =
state === 'migration-only'
? rollIsolated.has(cellId)
@@ -28,9 +28,7 @@ export async function reconcileCellAdmissionAtStartup(
// Admission is operator/director state. A new worker must not enable itself
// before its distinct candidate has passed production preflight.
if (config.role === 'cell') return
// Retries every transient failure, a lost reply included: the reconcile is upserts, a cap-only
// update and bound-change deletes, all of which converge on the same rows if a lost COMMIT had
// landed.
// Reconcile upserts/cap-only updates/bound-change deletes converge even after a lost COMMIT, so retry all transient failures.
await retryTransientDatabaseStartup(
async () => await assignments.reconcileCellsAtStartup(config.cells),
STARTUP_RECONCILE_RETRY,
@@ -1,9 +1,6 @@
import { DEAD_MAN_SLOT_HOLD_MS } from './cell-dead-man-trip-slot.js'
// The cell's raw admit mode in Postgres (relay_cell_admit_effective), so the database path can
// place on a tripped reserve cell even when no director's feed poll reaches it. Only the database
// path's PG-reserve cells need a row, so a cell writes only while its switch says reserve, plus
// one db write on the way out: with step 5 off, no cell writes anything.
// Publish raw mode so DB placement can use tripped reserve cells despite unreachable feeds. Write while switch says reserve plus one db exit write; step 5 off adds no writes.
export const CELL_ADMIT_EFFECTIVE_REFRESH_MS = 15_000
const CELL_ADMIT_EFFECTIVE_FAILURE_LOG_MS = 60_000
// A held dead-man asks for a trip slot this often; each ask is one short locked transaction.
@@ -9,8 +9,7 @@ import { createRelayServer } from './relay-server.js'
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
const describePostgres = databaseUrl ? describe : describe.skip
// c25 exited after 45 s of boot retries when its database was unreachable, and the MIG
// recreated it into the same loop. A cell now opens a lazy pool and listens at once.
// Lazy pool/listen avoids the unreachable-DB boot/MIG restart loop that hit c25 after 45s retries.
describePostgres('cell boot while PostgreSQL is unreachable', () => {
const cleanups: Array<() => Promise<void>> = []
+3 -12
View File
@@ -2,10 +2,7 @@ import { RELAY_ADMISSION_BUDGETS } from '@orca-cloud/relay-contract'
import type { RelayCellConfig } from './config.js'
import type { RelayDatabase, SqlRow } from './database.js'
// The one owner of relay_cell_connection_limits.hard_cap. The director's configured cap and the
// cap the cell last reported enforcing can differ while a cell is being resized; placement must
// never count seats a running process does not have, so every capacity reader takes the lower.
// A NULL report (a row no heartbeat has stamped yet) compares unknown and falls to the ELSE arm.
// During resize, placement uses min(configured, reported) to avoid phantom seats; unstamped NULL reports take configured cap via ELSE.
export const EFFECTIVE_HARD_CAP_SQL = `(CASE WHEN limits.reported_hard_cap < limits.hard_cap
THEN limits.reported_hard_cap ELSE limits.hard_cap END)`
@@ -45,9 +42,7 @@ export function readCellConnectionCap(row: SqlRow): CellConnectionCap {
}
}
// A cell may run below the configured cap (it has not been rolled onto a raise yet), never above
// it, and its unobserved allowance is part of the placement ceiling so it must match exactly.
// Returns the cap to record, or undefined when the report does not fit.
// Reported cap may lag below configured, never exceed it; unobserved bound must match placement’s ceiling exactly. Return undefined for invalid reports.
export function reportedHardCapWithinLimit(
limit: SqlRow,
report: { connectionHardCap?: number; connectionUnobservedBound?: number }
@@ -105,11 +100,7 @@ export async function recordReportedHardCap(
}
}
// A cap-only change (same unobserved bound, either direction) changes nothing placement reads
// beyond min(): the stored report stands, or failing one (a row an older director wrote) the cap
// the cell was configured with until now is recorded as its report, so a raise waits for the cell
// to report it. Telemetry rows survive, so a deploy that interleaves revisions with different caps
// cannot wipe the fleet's headroom. A bound change keeps the old fail-closed reset.
// Cap-only changes preserve reports/telemetry; seed missing legacy reports from the old cap so raises wait for heartbeat. This tolerates interleaved revisions; bound changes retain fail-closed reset.
export async function writeCellConnectionLimit(
transaction: RelayDatabase,
cell: Pick<RelayCellConfig, 'id' | 'connectionHardCap' | 'connectionUnobservedBound'>,
@@ -1,21 +1,15 @@
import { commitWithFinalWrite, type RelayDatabase } from './database.js'
// A tripped cell refreshes its own row every 15 s unless its pool has waiters, so a slot outlives
// a busy stretch; a cell that died tripped gives its slot back after this.
// Outlive busy refresh gaps, but release the slot of a cell that died while tripped.
export const DEAD_MAN_SLOT_HOLD_MS = 10 * 60_000
// A reserve cell's dead-man may trip only while fewer than `slots` other reserve cells are
// tripped, so a director outage moves a bounded number of cells onto their full database pools.
// A slot is held while the cell's own row says db and its switch (relay_cell_admit_modes) still
// says reserve. Only the cell writes that row, so it stays true with every director gone; a
// restarted or re-armed cell writes reserve and frees it.
// Bound database-pool growth during director outages; cell-owned db rows hold slots until restart, re-arm, or expiry.
export async function claimDeadManTripSlot(
database: RelayDatabase,
input: { cellId: string; cellIncarnation: string; slots: number; now: number }
): Promise<boolean> {
return await database.transaction(async (transaction) => {
// SQLite transactions already run one at a time. On PostgreSQL, concurrent claims would each
// count the others' uncommitted rows as free; a claim that finds the lock taken retries later.
// PostgreSQL claims must serialize to see occupied slots; SQLite already serializes transactions.
if (transaction.dialect === 'postgres') {
const [lock] = await transaction.query(
`SELECT pg_try_advisory_xact_lock(hashtext('orca-relay'), hashtext('dead-man-trip-slots')) AS locked`
+1 -2
View File
@@ -71,8 +71,7 @@ export type SupportedCellFlag =
// Parsed, never offered: ticketCheck=enforce has no step that turns it on yet.
const UNOFFERED_FLAG_VALUES: Record<string, string[]> = { ticketCheck: ['enforce'] }
// What this image accepts, read from the schema itself: the flag tool refuses a key or value
// the cell would drop or void before it writes.
// Derive supported flags from schema so tooling refuses values the cell would drop or invalidate before writing.
export function supportedCellFlags(): Record<string, SupportedCellFlag> {
const supported: Record<string, SupportedCellFlag> = {}
for (const [key, field] of Object.entries(CellFlagsSchema.shape)) {
@@ -60,9 +60,7 @@ describe('cell inventory hold samples', () => {
expect(samples.consumeCounts()).toEqual(emptyCellInventoryHoldCounts())
})
// Why: this is the case the hold metrics alone cannot see. A NOWAIT grab that
// fails has no duration, so a retry storm used to leave every hold field at
// zero while the lock was saturated.
// Failed NOWAIT has no duration; saturated retry storms must not leave all hold metrics looking healthy.
it('counts failed acquisitions in a window that recorded no holds', () => {
const samples = new CellInventoryHoldSamples()
for (let attempt = 0; attempt < 65; attempt++) samples.recordUnavailable()
@@ -1,9 +1,4 @@
// Why: the cell inventory lock is held to COMMIT, and the assignment path runs
// many statements after taking it. Tuning the request-path wait bound needs the
// hold distribution, and no runtime metric carried it before this change.
// Which lock a hold sample came from. Every site feeds the same max, so one
// alert on cellInventoryHoldMsMax covers them all; the label names the holder.
// 'isolated-replacement' is a drain return's regional target rows.
// Measure COMMIT-held lock durations to tune request waits. All sites feed shared max; labels identify holders, including drain-return regional rows as isolated-replacement.
export type CellLockHoldSite = 'inventory' | 'rehome-target-row' | 'isolated-replacement'
export type CellInventoryHoldCounts = {
@@ -20,12 +15,7 @@ export type CellInventoryHoldCounts = {
rehomeTargetRowHoldMsP99: number
isolatedReplacementHoldMsP99: number
isolatedReplacementHolds: number
// Why: a failed acquisition produces no hold sample, so the hold fields alone
// read healthy while the lock is saturated. Split by wait policy, not by
// caller: fail-fast covers background sweeps that step aside by design AND
// request-path first attempts that retry, so it reads as contention pressure,
// not user-visible failure. An expired bounded wait has already spent its
// budget, so that lane is the one that tracks stalls.
// Failed acquisitions have no duration. Fail-fast counts sweep skips and initial request retries as pressure; expired bounded waits measure stalls separately.
cellInventoryLockUnavailable: number
cellInventoryLockTimeouts: number
}
@@ -173,10 +173,7 @@ describe('bounded cell-inventory lock wait', () => {
await database.close()
})
// Why: testing the helper alone would pass with the store still queueing for
// the pool's one-second default.
// Why: testing the helper alone would pass with the request path still queueing
// for the pool's full second.
// Helper-only tests miss request paths still waiting for the full one-second pool default.
it('never lets a request path take the unbounded wait', async () => {
const database = await openInMemoryRelayDatabase()
const probe = new InventoryLockProbe(database)
@@ -292,9 +289,7 @@ describe('bounded cell-inventory lock wait', () => {
await database.close()
})
// Why: a bounded request-path wait raises the same 55P03 without NOWAIT. Folding
// it into the deferral counter would hide user-visible stalls among by-design
// sweep skips, which outnumber them by roughly an order of magnitude.
// Separate bounded 55P03 timeouts from intentional NOWAIT skips or frequent sweep deferrals hide user-visible stalls.
it('counts an expired bounded wait apart from a NOWAIT deferral', async () => {
const database = await openFakePostgres()
fakes.query.mockImplementation(async (sql: string) => {
@@ -316,9 +311,7 @@ describe('bounded cell-inventory lock wait', () => {
).rejects.toThrow()
const counts = consumeRelayCellInventoryHold(database)
// One per attempt, not per request: 55P03 is retryable, so an exhausted
// request contributes POSTGRES_TRANSACTION_ATTEMPTS timeouts. Reading the
// metric as affected-requests would overstate it threefold.
// Count attempts, not requests: three retryable 55P03 timeouts would overstate affected requests threefold.
expect(counts.cellInventoryLockTimeouts).toBe(3)
expect(counts.cellInventoryLockUnavailable).toBe(0)
await database.close()
@@ -5,10 +5,7 @@ import { openRelayDatabase, type RelayDatabase } from './database.js'
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
const describePostgres = databaseUrl ? describe : describe.skip
// Sorted ascending, and the host is pinned to the LAST id on purpose: the
// fleet-wide lock is one ordered scan, so it holds every earlier row while it
// waits on the pinned one. Pinning to the first id would make the two locking
// models indistinguishable.
// Pin to last ascending cell: a fleet scan holds earlier rows while waiting, whereas per-cell locking leaves them free; first-cell pins cannot distinguish these.
const cells = ['a', 'b', 'c'].map((suffix) => ({
id: `percell-postgres-${suffix}`,
url: `https://percell-postgres-${suffix}.example.com`,
@@ -102,9 +99,7 @@ describePostgres('PostgreSQL per-cell inventory locking', () => {
return false
}
// Why: a sticky refresh whose first NOWAIT probe loses retries by taking a
// cell row before the assignment row. That retry used to take the whole
// inventory, so one busy cell stalled every other cell's reconnects.
// Sticky NOWAIT retry must lock only the pinned cell first; fleet-wide retry previously stalled every cell’s reconnects.
it('waits only on the pinned cell row while refreshing a sticky assignment', async () => {
await removeTestRows(databases[0]!)
const store = new RelayAssignmentStore(databases[0]!, () => 100)
@@ -1,6 +1,4 @@
// Whether this director's own seat-feed polls reach a cell: one half of the evidence that lets a
// host that lost its control leave a dead reserve cell without an operator fence. The other half
// is the cell's stale heartbeat, which travels a different path (cell -> director -> Postgres).
// Unanswered director feed polls plus stale heartbeat (cell→director→Postgres) prove reserve-cell death, allowing only host-requested departure without an operator fence.
// The failing streak must span this long: the heartbeat TTL, so neither signal outruns the other.
export const CELL_POLL_DEAD_STREAK_MS = 45_000
@@ -57,8 +55,7 @@ export class CellPollReachability {
for (const cellId of this.streaks.keys()) if (!cellIds.has(cellId)) this.streaks.delete(cellId)
}
// Any current streak, however young: a staggered partition must not let its first cell cross 45 s
// while its neighbours are still a few seconds behind and read as healthy.
// Count even young failure streaks or staggered partition starts can make the first dead verdict outrun its neighbors.
private failing(cellId: string, now: number): boolean {
const streak = this.streaks.get(cellId)
return streak !== undefined && now - streak.lastFailedAt <= CELL_POLL_DEAD_MAX_GAP_MS
@@ -74,8 +71,7 @@ export class CellPollReachability {
)
}
// Null unless this cell is unreachable. Every director runs in one region, so a partition from
// another region fails every cell there for all of them at once: half a region is never a cell.
// Regional directors share cross-region partitions; half a region failing cannot prove individual cell death.
verdict(cellId: string, polled: readonly PolledCell[], now: number): DeadCellVerdict | null {
if (!this.unreachable(cellId, now)) return null
const region = polled.find((cell) => cell.cellId === cellId)?.region
@@ -16,8 +16,7 @@ import type { AppliedControlFlags } from './relay-control-flag-channel.js'
import { createRelayServer, HOST_HELLO_SHED_OLDEST_WAIT_MS } from './relay-server.js'
import { RELAY_WEBSOCKET_FORCE_CLOSE_MS } from './relay-websocket-close.js'
// A step-5 cell whose database pool is wedged: every query waits forever and the hello shed
// is armed. Booked and recently seated hosts must still connect; everything else must not.
// Wedged database and armed shedding must still admit booked/recently seated hosts, while refusing strangers.
async function unusedPort(): Promise<number> {
const server = createNetServer()
@@ -448,10 +447,7 @@ describe('a reserve-mode cell admits from memory with its database wedged', () =
expect(warned('orca_relay_cell_seat_log_drift')).toEqual([])
}, 10_000)
// A drained session leaves the map at once, but its socket closes only after its release (up
// to 3 s) and the peer's close reply (up to the 1 s force-close). A desktop of the same
// identity dialling back in that window restarted at generation 1, so the old socket's late
// leave, at generation 1 too, removed the live seat from the feed.
// Drained sockets can close seconds after map removal; reusing their generation let late leaves erase reconnected live seats.
it('keeps a session that reconnected while its drained predecessor was still closing', async () => {
const cell = await startCell({ admitMode: 'reserve' })
const identity = await cell.host()
@@ -478,8 +474,7 @@ describe('a reserve-mode cell admits from memory with its database wedged', () =
expect(seatEpochs(cell)).toEqual([10])
}, 10_000)
// The same identity connecting afresh at each turn, as desktops sharing one identity do: each
// turn replaces the session and its seat together, so no sweep finds anything to repair.
// Repeated fresh joins for one identity replace session and seat together, leaving no drift to repair.
it('stays on the live session when the same identity connects afresh each turn', async () => {
const cell = await startCell({ admitMode: 'reserve' })
const identity = await cell.host()
+2 -4
View File
@@ -4,8 +4,7 @@ import {
type ReserveOutcome
} from './cell-reserve-contract.js'
// The cell's half of step 5: it alone says yes or no to each booking. One event loop makes
// "check cap and budget, then count the booking" atomic with no lock.
// Single event loop makes booking cap/budget admission and accounting atomic without locks.
// A connection unit held for a booked host until its hello consumes it or the TTL ends.
export type BookingUnit = { release(): void }
@@ -130,8 +129,7 @@ export class CellReserveBook {
return booking !== undefined && booking.expiresAt > this.now()
}
// The booked host's upgrade takes over its unit, so the host counts once until its hello.
// The upgrade carries no epoch; the hello's take() still requires the booked one.
// Upgrade takes the booking’s unit to count once; epoch-less upgrade still needs exact-epoch take at hello.
handOff(userId: string, relayHostId: string): void {
const booking = this.bookings.get(hostKey(userId, relayHostId))
if (!booking || booking.handedOff || booking.expiresAt <= this.now()) return
+1 -3
View File
@@ -9,9 +9,7 @@ import {
} from './cell-reserve-contract.js'
import type { ReserveAttempt } from './reserve-placement.js'
// The director's calls to cells. Bookings for one cell batch for up to 5 ms or 64 items;
// a cell that does not answer within the timeout is unverifiable, and every item in the
// batch reads `unreachable` (one of the request's three tries).
// Batch bookings per cell for 5ms/64 items; unreadable replies mark the whole batch unreachable for one of the request’s three tries.
export const CELL_RESERVE_FLUSH_MS = 5
export const CELL_RESERVE_TIMEOUT_MS = 1_000
@@ -1,8 +1,6 @@
import { z } from 'zod'
// Step 5 wire between directors and cells (v1). Directors book a seat on a cell before
// answering a desktop; the cell admits or refuses each booking itself. Readers ignore
// unknown fields, so either side may add optional ones.
// v1 director/cell booking wire: cell decides each seat before desktop assignment; ignore unknown fields for independent upgrades.
export const CELL_RESERVE_PATH = '/internal/reserve'
export const CELL_DEMOTE_PATH = '/internal/demote'
@@ -2,14 +2,11 @@ import { createHash } from 'node:crypto'
import type { CellFlags } from './cell-flags.js'
import type { AppliedControlFlags } from './relay-control-flag-channel.js'
// A reserve-mode cell keeps its hosts in memory only while a placing director is there to
// book and supersede them. With none for this long (a director rolled back, or placement
// turned off) it flips itself back to db and re-registers, as a switch flip would.
// Without placing-director contact, memory-only reserve ownership cannot supersede hosts; flip back to db and re-register as a switch flip would.
export const RESERVE_DEAD_MAN_MS = 60_000
const RESERVE_DEAD_MAN_SPREAD_MS = 60_000
// 60-120 s, fixed per cell: when every director goes at once, the cells trip (and start
// re-registering against the database) spread over a minute, not together.
// Stable 60–120s per-cell windows stagger fleet-wide fallback DB re-registration over one minute.
export function reserveDeadManWindowMs(cellId: string): number {
const digest = createHash('sha256').update(cellId).digest()
return RESERVE_DEAD_MAN_MS + (digest.readUInt32BE(0) % (RESERVE_DEAD_MAN_SPREAD_MS + 1))
@@ -40,8 +37,7 @@ export class ReserveDeadMan {
this.permittedGeneration = null
}
// The admitMode the cell acts on. Latched per switch-file generation: once tripped, only a
// new write brings reserve back, so a director that returns cannot flap the cell.
// Latch fallback per switch generation; returning directors cannot flap mode until a new reserve write.
mode(applied: AppliedControlFlags<CellFlags>): 'db' | 'reserve' {
if (applied.flags.admitMode !== 'reserve') {
this.reserveGeneration = null
@@ -7,9 +7,7 @@ import { openRelayDatabase, type RelayDatabase } from './database.js'
import { openDelayedPostgresDatabase, type StatementDelay } from './test-fixtures/delayed-postgres-database.js'
import { RESERVE_MODE_CELL_ID, startReserveModeCell } from './test-fixtures/reserve-mode-cell.js'
// E-mix on PostgreSQL: a reserve-mode cell flipped back to db leases every control it admitted
// from memory, disconnecting nobody, with the pool mostly left to DB-path work and one
// relay_cells write per tick, even with a cross-region round trip on every statement.
// PostgreSQL flip-back keeps controls connected, reserves most pool capacity for DB work and folds cell deltas per tick even across cross-region round trips (E-mix).
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
const describePostgres = databaseUrl ? describe : describe.skip
@@ -229,8 +229,7 @@ describe('flipping a reserve-mode cell back to the database', () => {
expect(reply.socket.readyState).toBe(WebSocket.OPEN)
}, 30_000)
// Step 5 off stays write-free: a db switch (read, generation > 0) writes no row, nor does a
// booted cell before its first flag read. Leaving reserve writes db once, then nothing.
// Step 5 off adds no writes before/after flag reads; leaving reserve owes exactly one db write.
it('writes an admit-effective row only while its switch says reserve, plus one db write on the way out', async () => {
let now = Date.now()
const booting = await cell(() => now, { flagsUnread: true })
@@ -275,8 +274,7 @@ describe('flipping a reserve-mode cell back to the database', () => {
.mock.calls.map(([line]) => String(line))
.filter((line) => line.includes('orca_relay_cell_reserve_dead_man_tripped'))
expect(tripped.map((line) => JSON.parse(line).cellId)).toEqual([reserve.config.cellId])
// The cell's own row says db at once, while Postgres still records reserve: the database
// path places on it by this row, whether or not any director's feed poll gets through.
// Publish raw db mode immediately despite PG’s reserve stamp so placement can recover without a working feed poll.
await until(async () => (await reserve.admitEffectiveRow())?.mode === 'db')
expect((await reserve.admitEffectiveRow())?.cell_incarnation).toBe(reserve.relay.cellIncarnation)
await until(async () => (await reserve.controlLeases()).length === 2)
+1 -2
View File
@@ -13,8 +13,7 @@ import { readBearer } from './relay-token-verifier.js'
const MAX_BODY_BYTES = 16 * 1024
// The directors' rehome credential, as the seat feed uses. Unlike the feed these write, but a
// director can already place any host through the database, so they grant nothing new.
// Reuse director rehome credentials for writes; directors can already place any host via DB, so these grant no new authority.
export function registerCellReserveRoutes(
app: Hono,
config: RelayConfig,
+1 -2
View File
@@ -7,8 +7,7 @@ import type { AppliedControlFlags } from './relay-control-flag-channel.js'
import type { RelayRuntimeCounts } from './relay-observability.js'
import { readBearer } from './relay-token-verifier.js'
// Read-only, so it reuses the directors' rehome credential and grants it nothing new. Only
// cells that already hold the rehome pair serve it: giving a cell the pair turns rehome on.
// Reuse read-only rehome credentials only on cells already holding the pair; distributing it further would enable rehome.
export function registerCellSeatFeedRoute(
app: Hono,
config: RelayConfig,
+1 -10
View File
@@ -1,13 +1,4 @@
// Seat changes on this cell, read by directors through `GET /v1/admin/cell-seats`.
// Wire contract (v1); readers must ignore unknown fields:
// since=<incarnation>:<seq> returns `changes` after seq, or `full` when the cursor is
// absent, from another incarnation, or older than the ring. `seq` in the reply is the
// cursor for the next poll; `more` says a page was cut at CELL_SEAT_FEED_PAGE_MAX.
// Apply a change only when its generation is >= the seat's: a superseded generation's
// socket can close after its successor joined. `full` is this log's own seats under the
// same rules, so a reader that applies every change holds exactly `counts.seats`.
// Step 5 adds `reserve` (a booking, never a seat) and `reservedBy` on the join it admitted;
// a full snapshot also carries `recentlyLeft`, this cell's leavers of the last 10 minutes.
// v1 feed: ignore unknown fields. since=<incarnation>:<seq> returns changes or full for absent/stale/wrong-incarnation cursors; reply seq continues and more signals paging. Apply generations >= current so late superseded closes cannot erase successors. Full seats obey the same rules/counts. Reserve is a booking, reservedBy identifies join’s booker, and recentlyLeft spans ten minutes.
export type CellSeatState = 'active' | 'drain-only'
@@ -1,8 +1,7 @@
const INSTANCE_ZONE_URL = 'http://metadata.google.internal/computeMetadata/v1/instance/zone'
const REQUEST_TIMEOUT_MS = 3_000
// The metadata server answers `projects/<number>/zones/<zone>`. Undefined on any failure:
// the zone only labels log lines, so it must never hold up or fail a boot.
// Zone only labels logs; metadata lookup failure must not fail boot.
export async function readCloudRunInstanceZone(
fetchImpl: typeof fetch = fetch
): Promise<string | undefined> {
+1 -2
View File
@@ -36,8 +36,7 @@ const OptionalServiceAccountSchema = z.preprocess(
z.string().email().optional()
)
// 0 disables the window and restores the fail-on-first-error readiness answer. An unset variable
// arrives as '' from Cloud Run, which z.coerce would read as 0 rather than as the default.
// Cloud Run unset values arrive as empty strings, which coerce to 0 (fail-first readiness) instead of the grace default.
const readinessGraceSchema = (defaultMs: number) =>
z.preprocess(
(value) => (value === '' ? undefined : value),
@@ -19,10 +19,7 @@ const userId = 'accept-lock-postgres-user'
const first = { userId, relayHostId: 'acceptlockhost01' }
const second = { userId, relayHostId: 'acceptlockhost02' }
// Why: the accept path now enforces the capacity ceiling inside its single
// conditional cell-row write instead of behind a SELECT ... FOR UPDATE it held
// for the rest of the transaction. Two accepts reaching for the same last slot
// are what would expose a lost update if that check were no longer atomic.
// Race accepts for the last slot: the final conditional cell UPDATE must enforce capacity atomically without an earlier held SELECT lock.
describePostgres('PostgreSQL control accept without a held cell row', () => {
const databases: RelayDatabase[] = []
@@ -151,10 +148,7 @@ describePostgres('PostgreSQL control accept without a held cell row', () => {
})
await heldPromise
// Retiring generation 1 and installing generation 2 leaves the cell's
// reservation where it was, so the accept has no reason to wait on the row
// at all. Reading it up front is what used to make it wait, and then fail
// at the request-path lock bound.
// Same-cell generation replacement changes no reservation; reading the shared row would needlessly wait and hit the request bound.
try {
await expect(
store.activateControl(first, {
@@ -180,8 +174,7 @@ describePostgres('PostgreSQL control accept without a held cell row', () => {
expect(Number(cells[0]!.reserved_requests)).toBe(Number(units[0]!.units))
}, 20_000)
// The fused commit is optional on RelayDatabase, so a wrapper that stopped forwarding it
// would silently fall back to a separate COMMIT. Drive the store the server builds.
// Drive the production store: wrappers can omit optional fused commit and silently restore a separate COMMIT round trip.
it('commits every counter write in one message through the production store', async () => {
await removeTestRows(databases[0]!)
const store = new RelayAssignmentStore(databases[0]!, () => 100)
@@ -50,11 +50,7 @@ function heartbeat(cell: (typeof cells)[number]) {
}
}
// Why: every desktop control rebind used to take the fleet-wide relay_cells
// FOR UPDATE lock, so a rebind on one cell queued behind whatever held any
// other cell's row, until COMMIT (55P03 at the request bound). A rebind only
// touches its own cell row, so it must proceed while another cell's row is
// held elsewhere.
// Same-cell rebind must proceed while another cell row is held; fleet-wide locking formerly waited to COMMIT and hit request 55P03.
describePostgres('PostgreSQL control rebind under a held cell row', () => {
const databases: RelayDatabase[] = []
@@ -191,10 +187,7 @@ describePostgres('PostgreSQL control rebind under a held cell row', () => {
}
}, 15_000)
// Why: a phone's activity id is client-chosen and can follow the host across
// a migration, so acquireActivity may touch two cell rows. Moving from the
// higher cell to the lower one is where an unordered lock cycles with
// placement's ascending inventory lock (reproduced live before this fix).
// Client activity IDs can migrate across cells; high→low moves expose lock cycles against ascending placement order.
it('moves an activity from a higher cell to a lower one in cell_id order', async () => {
await removeTestRows(databases[0]!)
const [cellA, cellB, cellC] = cells as [(typeof cells)[0], (typeof cells)[0], (typeof cells)[0]]
@@ -219,9 +212,7 @@ describePostgres('PostgreSQL control rebind under a held cell row', () => {
applicationName: 'orca-relay/cloud3-rebind-move'
})
const moveStore = new RelayAssignmentStore(movingDatabase, () => 100)
// Hold B elsewhere. An ordered move locks B first and queues here holding
// nothing else. Locking C first (the old lease's row, as an unordered move
// does) or the whole inventory (which takes A) shows up as a held row.
// Hold B: ordered B→C move waits with no other lock; C-first or fleet-wide locking would hold C or A.
let releaseRow!: () => void
const rowReleased = new Promise<void>((resolve) => {
releaseRow = resolve
+3 -12
View File
@@ -4,15 +4,9 @@ import type {
ControlRenewalRequest
} from './control-renewal-statement.js'
// One flush per second turns the fleet's control-lease write rate into a
// function of the cell count rather than the host count: a cell's ~10 due
// renewals per second become one write transaction instead of ten. Well inside
// the 105s lease runway, so a host that misses a window is never at risk.
// One flush per cell/sec replaces ~10 per-host transactions; missed windows remain within the 105s lease runway.
export const CONTROL_RENEWAL_BATCH_INTERVAL_MS = 1_000
// Ceiling on the parameter arrays. Row locks live until the statement commits,
// so this is what bounds how long one flush holds them: measured at 11.5ms for
// 200 rows against a 20,000-row table, and 9.4ms with a host wedged in a
// per-host transaction.
// Bound row locks/parameters: 200 rows measured ~11.5ms on 20k rows and ~9.4ms with one host wedged.
export const CONTROL_RENEWAL_BATCH_MAX_ROWS = 200
// A flush slower than this is the only latency worth a line; the metrics event
// carries the distribution.
@@ -36,10 +30,7 @@ function pendingKey(request: ControlRenewalRequest): string {
return [request.identity.userId, request.identity.relayHostId].join('\u0000')
}
// Collects the control-lease renewals a cell owes and spends one statement on
// them. Each caller still gets the single-renewal contract: the promise resolves
// on `renewed` and rejects with the outcome as its message otherwise, so callers
// keep their per-session error routing unchanged.
// Preserve per-caller renewal contract: resolve renewed, otherwise reject with its outcome for unchanged session error routing.
export class ControlRenewalBatch {
private pending = new Map<string, PendingRenewal>()
// Renewals a host cannot contribute to the flush being built; they open the
@@ -449,9 +449,7 @@ describePostgres('PostgreSQL control renewal', () => {
it('renews both of one host\u2019s control leases in a single batch', async () => {
const store = new RelayAssignmentStore(database, () => now)
const identity = identities[13]!
// Two live control leases on one host. Written directly because
// activateControl retires the prior generation, and what is under test is the
// statement's row-wise behaviour, not how the second lease came to exist.
// Seed duplicate live controls directly; activateControl retires predecessors, but this case tests row-wise batch behavior.
await database.query(
`INSERT INTO relay_assignment_activity_leases
(user_id, relay_host_id, activity_id, activity_kind, cell_id,
@@ -7,9 +7,7 @@ export type ControlRenewalOutcome =
| 'activity_cell_not_authoritative'
| 'control_activity_not_found'
| 'control_activity_moved'
// The host's assignment row was already locked by one of the per-host
// transactional paths. Retryable, and never a reason to close a control: the
// next tick is 15s away and the lease has 105s on it.
// Contended assignment is retryable, never grounds to close control: next tick is 15s away with a 105s lease.
| 'assignment_lock_unavailable'
// Decided per row before the statement runs, so one malformed request cannot
// cost the rest of the batch its renewal.
@@ -64,14 +62,7 @@ export function orderedControlRenewalRows<Row extends { identity: AssignmentIden
)
}
// One statement renewing every due control lease on this cell, row-wise over the
// unnested parameter arrays. Logic per row is what the single-row predecessor
// did: lock the assignment, admit the caller's cell either as the current cell or
// as the source of an active forward migration, lock that host's control lease,
// push both expiries forward, and report one outcome. The one addition is
// `present_assignment`, an unlocked probe that separates a host with no
// assignment row at all from one whose row SKIP LOCKED passed over - the first
// closes the control, the second retries.
// Renew unnested rows like single-host logic: lock assignment, authorize current/forward-source cell, lock lease, extend expiries and report. Unlocked present_assignment distinguishes missing (close) from SKIP LOCKED (retry).
export const CONTROL_RENEWAL_BATCH_SQL = `WITH renewal_input AS MATERIALIZED (
SELECT
renewal.ordinality AS row_index,
@@ -8,10 +8,7 @@ import {
} from './credential-store.js'
import { openRelayDatabase, type RelayDatabase } from './database.js'
// The outage this guards against: the credential cleanup ran every 30s in all 23 cells and both
// sweeps over relay_invites had no usable index, so each one seq-scanned the whole table inside the
// maintenance transaction. Only a real planner can show the partial indexes take that away, and
// only a real server has ctid.
// Real planner/ctid coverage: 23 cells’ 30s invite sweeps once scanned the whole table; partial indexes must remove those scans.
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
const describePostgres = databaseUrl ? describe : describe.skip
const schema = 'relay_credential_sweep_test'
@@ -129,9 +126,7 @@ describePostgres('credential cleanup against PostgreSQL', () => {
const expiry = await cleanupPlan('UPDATE relay_invites', 0)
const reservation = await cleanupPlan('UPDATE relay_invites', 1)
// Which of the two partial indexes serves the reservation pass is the planner's call: both
// predicates hold only live invites, so either one reads a handful of rows. The invariant is
// that neither pass reads the whole table any more.
// Either partial index may win; both restrict to live invites, and neither sweep may scan the whole table.
for (const sweep of [expiry, reservation]) {
expect(sweep).not.toContain('Seq Scan on relay_invites')
expect(sweep).toMatch(/using relay_invites_sweep_(expiry|reservation)/)
@@ -140,10 +135,7 @@ describePostgres('credential cleanup against PostgreSQL', () => {
})
it('plans the basis sweep off the composite index rather than the 1.5 GB heap', async () => {
// The shape that made this the most expensive statement in the sweep: 20,000 settled bases to
// 50 live ones. A partial index on active = 1 looks like the answer to that ratio and is not:
// a basis is inserted active and flipped to 0, so it accumulates the same dead entries, and
// the planner picks the composite index anyway. See the schema comment beside it.
// Active→inactive basis flips leave dead partial-index entries; with 20k settled/50 live the planner still picks the composite index (see schema).
await database.query(
`INSERT INTO relay_connection_bases
(basis_conn_id, user_id, relay_host_id, relay_device_id, owning_control_generation,
@@ -169,11 +161,7 @@ describePostgres('credential cleanup against PostgreSQL', () => {
})
it('plans the drained basis reaper off the composite index, not the heap', async () => {
// Why the composite index stays for now: it is the only one covering active = 0, and the case
// that needs it is the steady state, where every row is inside retention and the reaper must
// learn there is nothing to do. While the backlog drains the planner rightly prefers a bounded
// sequential scan, because it finds its 5,000 rows and stops; measured at 200k rows, the
// drained batch costs 5 buffers with this index and 1,274 without it.
// Keep composite index for drained active=0 retention checks (5 vs 1,274 buffers at 200k rows); backlog batches correctly favor bounded seq scans.
await database.query(
`INSERT INTO relay_connection_bases
(basis_conn_id, user_id, relay_host_id, relay_device_id, owning_control_generation,
@@ -127,8 +127,7 @@ describe('credential cleanup invite reaper', () => {
})
it('never deletes an invite that a reader could still consume, however old', async () => {
// Retention is measured on updated_at, and a long-lived available invite has an old one. The
// state filter is what keeps the reaper from deleting a credential still in use.
// Old updated_at does not settle a live invite; state guard must prevent reaping it.
const database = await openInMemoryRelayDatabase()
const store = new RelayCredentialStore(database, () => NOW)
const ancient = NOW - 400 * DAY_MS
@@ -168,8 +167,7 @@ describe('credential cleanup invite reaper', () => {
})
it('still expires credentials the sweep owns, and only those past their deadline', async () => {
// The reaper runs after the sweep in the same call, so this pins that adding it did not
// displace any of the five state transitions the sweep is there for.
// Reaper follows sweep; ensure all five prior transitions still run.
const database = await openInMemoryRelayDatabase()
const store = new RelayCredentialStore(database, () => NOW)
await insertInvite(database, {
@@ -245,16 +243,12 @@ describe('credential cleanup invite reaper', () => {
})
it('reaps connection bases whose deadline passed over a day ago, and nothing else', async () => {
// Retention is measured on deadline, and both readers of a basis require deadline >= now, so a
// deadline a day in the past is already unusable however the active flag reads. The active = 0
// clause is what keeps the batch an index range, not what makes the row safe to delete.
// Passed deadlines make bases unusable regardless of active flag; active=0 provides an index range, not deletion safety.
const database = await openInMemoryRelayDatabase()
const store = new RelayCredentialStore(database, () => NOW)
await insertBasis(database, { id: 'stale-inactive', active: 0, deadline: NOW - 2 * DAY_MS })
await insertBasis(database, { id: 'recent-inactive', active: 0, deadline: NOW - 60_000 })
// A long-lived splice: still active hours after the 30s deadline it was created with. The
// sweep deactivates it this cycle and the reaper takes it in the same call, which is safe
// precisely because no reader would have accepted it since its deadline passed.
// Sweep deactivates this long-lived splice and reaps it immediately; no reader accepts its already-expired basis.
await insertBasis(database, { id: 'stale-active', active: 1, deadline: NOW - 400 * DAY_MS })
await insertBasis(database, { id: 'live-active', active: 1, deadline: NOW + DAY_MS })
+10 -28
View File
@@ -10,29 +10,20 @@ import { HeapWindowReaper } from './heap-window-reaper.js'
import { ResumeConfirmedAuditBuffer } from './resume-confirmed-audit-buffer.js'
const CREDENTIAL_GRACE_MS = 24 * 60 * 60 * 1000
// Released desktops validate invite expiry against their own clock with zero
// tolerance at exactly inviteTtlMs; issuing under the ceiling keeps pairing
// working for clients whose clocks trail the cell by up to this margin.
// Issue below released desktops’ zero-tolerance inviteTtlMs ceiling so clients lagging by this margin can still pair.
const INVITE_ISSUE_SKEW_MARGIN_MS = 30 * 1000
// Terminal invites are read by nothing: every reader re-checks expiry and state at read time, so
// the row only serves the audit trail, which relay_audit_events already keeps. A week is long
// enough to answer a support question about a pairing that failed.
// Terminal invite readers recheck state/expiry; retain one week only for pairing support, with audit events already durable.
const TERMINAL_INVITE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000
// Why: both readers of a connection basis and of a direct authorization require it still
// active/unconsumed AND inside its deadline, and every deadline is set at most 30s past insert, so
// a settled row can never authorize anything again. A day is margin for forensics, not for reads.
// Readers require active/unconsumed and unexpired ≤30s deadlines; settled rows cannot authorize again, so one day is forensic margin.
const INACTIVE_AUTHORIZATION_RETENTION_MS = 24 * 60 * 60 * 1000
// A stored confirm result only answers a retry of the same request on the same connection basis,
// and a different basis is refused as a tuple mismatch; a basis lives for one phone connection. A
// week matches the pairing support window above.
// Confirm retries must match the same request and per-phone connection basis; keep one week for pairing support.
export const CONFIRM_RESULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000
// Nothing in the relay reads audit events back; 90 days covers support and incident questions.
export const AUDIT_EVENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000
// Bounded so one cycle cannot hold row locks or grow WAL without limit; the backlog drains over
// however many cycles it takes.
const REAP_BATCH_ROWS = 5000
// ~14 rows per page. With ~9 director ticks a minute the row cap is ~3M rows a day, so the 7.4M-row
// backlog drains over two to three days; a walk that finds nothing reads ~1 MB a tick.
// At ~14 rows/page and ~9 ticks/min, budget reaps ~3M rows/day (~2–3 days for 7.4M backlog); empty walks read ~1MB/tick.
const CONFIRM_RESULT_REAP_BUDGET = {
pagesPerStatement: 16,
maxPagesPerTick: 128,
@@ -590,9 +581,7 @@ export class RelayCredentialStore {
})
}
// Step 6's confirm for a basis the cell holds in memory: one fresh device read, and a renewal
// write at most once a day per device. A current-version confirm always answers renewed:true
// with the expiry the database now holds, so a phone keeps scheduling its renewals.
// Memory confirm reads one device and renews at most daily; current-version replies always use renewed:true with stored expiry so phones schedule future renewals.
async confirmMemoryResume(
input: RelayIdentity & {
reqId: string
@@ -643,8 +632,7 @@ export class RelayCredentialStore {
}
}
// Guarded by the row just read, so a revoke or install that committed since leaves it alone;
// the answer then keeps the expiry that was read, which the database still holds.
// Guard against revoke/install since the read; on no-op keep the expiry read, which the database still holds.
private async renewResume(
identity: RelayIdentity & { relayDeviceId: string },
device: SqlRow,
@@ -782,18 +770,14 @@ export class RelayCredentialStore {
await this.reapSettledCredentials(now)
}
// Outside the sweep transaction on purpose: each delete is idempotent and independent of the
// state transitions above, so batching them in would only hold their row locks for longer.
// Idempotent reaping stays outside transition transaction or locks last across unrelated batch deletes.
private async reapSettledCredentials(now: number): Promise<void> {
await this.reapBatch(
'relay_invites',
'state IN (?, ?, ?) AND updated_at <= ?',
['expired', 'consumed', 'invalidated', now - TERMINAL_INVITE_RETENTION_MS]
)
// deadline, not created_at: it is the second column of relay_connection_bases_active_deadline,
// so once the backlog is drained this batch learns there is nothing left to do from the index
// instead of the 1.5 GB heap. Both readers reject a passed deadline, so a day past one is
// unusable whatever the active flag says.
// Use indexed active/deadline range to avoid scanning 1.5GB drained heap; both readers reject expired deadlines regardless of flag, so one-day-old bases are unusable.
await this.reapBatch('relay_connection_bases', 'active = ? AND deadline <= ?', [
0,
now - INACTIVE_AUTHORIZATION_RETENTION_MS
@@ -811,9 +795,7 @@ export class RelayCredentialStore {
await this.reapBatch('relay_audit_events', 'at <= ?', [now - AUDIT_EVENT_RETENTION_MS], 'at')
}
// ctid/rowid, not the primary key: the physical address lets the delete re-find exactly the batch
// the subquery located instead of re-matching the predicate per row. On Postgres an array of TIDs,
// not `IN`: IN can plan as a hash join over a sequential scan of the whole table.
// Delete located physical ctid/rowid; PostgreSQL ANY(TID array) guarantees TID scan, while IN can hash-join a full heap scan.
private async reapBatch(
table: string,
predicate: string,

Some files were not shown because too many files have changed in this diff Show More