mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
[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:
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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[] = []
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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>> = []
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user