Merge remote-tracking branch 'origin/main' into brennanb2025/i18n-catalog-decoupling

# Conflicts:
#	.github/workflows/pr.yml
#	package.json
#	pnpm-lock.yaml
This commit is contained in:
Brennan Benson
2026-07-29 15:13:00 -07:00
5594 changed files with 657713 additions and 96830 deletions
@@ -0,0 +1,68 @@
name: Install Node dependencies
description: Installs the Node toolchain and repository dependencies for Linux CI jobs.
inputs:
native-runtime:
description: Native runtime to prepare after the script-free install (none, node, or electron).
required: false
default: none
runs:
using: composite
steps:
# setup-node needs pnpm on PATH to locate and restore its store.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Validate native runtime
shell: bash
env:
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
run: |
case "$NATIVE_RUNTIME" in
none|node|electron) ;;
*)
echo "::error::native-runtime must be none, node, or electron"
exit 2
;;
esac
# pnpm's bundled gyp_main.py is not executable on fresh Linux runners.
- name: Use external node-gyp
if: inputs.native-runtime != 'none'
shell: bash
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Prepare dependency install
shell: bash
run: |
if [ -e node_modules ]; then
ls -ld node_modules
rm -rf node_modules
fi
- name: Install dependencies
shell: bash
run: |
pnpm install \
--no-frozen-lockfile \
--prefer-frozen-lockfile=false \
--ignore-scripts
git diff --exit-code package.json pnpm-lock.yaml
- name: Prepare native runtime
if: inputs.native-runtime != 'none'
shell: bash
env:
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"
+11 -1
View File
@@ -6,6 +6,8 @@ on:
- '.github/workflows/computer-e2e.yml'
- 'config/electron-builder.config.cjs'
- 'config/scripts/build-computer-macos.mjs'
- 'config/scripts/build-windows-cli-launcher.mjs'
- 'config/scripts/build-windows-cli-launcher.test.mjs'
- 'config/scripts/computer-e2e-workflow.test.mjs'
- 'config/scripts/computer-use-skill-guidance.test.mjs'
- 'config/scripts/computer-use-smoke.mjs'
@@ -22,12 +24,15 @@ on:
- 'native/computer-use-macos/**'
- 'native/computer-use-linux/**'
- 'native/computer-use-windows/**'
- 'native/windows-cli-launcher/**'
- 'skills/computer-use/SKILL.md'
- 'src/cli/**'
- 'src/main/computer/**'
- 'src/main/runtime/rpc/dispatcher.ts'
- 'src/main/runtime/rpc/errors.ts'
- 'src/main/runtime/rpc/methods/computer*.ts'
- 'src/main/ssh/ssh-remote-cli-launcher.ts'
- 'src/main/ssh/ssh-remote-cli-launcher.test.ts'
- 'src/shared/computer-use-*.ts'
- 'tests/e2e/computer-linux.e2e.ts'
- 'tests/e2e/computer-mac.e2e.ts'
@@ -70,9 +75,14 @@ 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"
- run: pnpm install --frozen-lockfile
# 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.
- run: >-
pnpm vitest run
pnpm vitest run --config config/vitest.config.ts
config/scripts/build-windows-cli-launcher.test.mjs
src/main/ssh/ssh-remote-cli-launcher.test.ts
config/scripts/computer-e2e-workflow.test.mjs
config/scripts/computer-use-skill-guidance.test.mjs
config/scripts/computer-use-smoke.test.mjs
+17 -2
View File
@@ -1,5 +1,11 @@
name: E2E
run-name: E2E ${{ inputs.ref || github.ref }}
# Why: checkout + artifact upload only; callers can only further restrict.
permissions:
contents: read
on:
workflow_call:
inputs:
@@ -76,7 +82,7 @@ jobs:
name: e2e ${{ matrix.shard_name }}
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@@ -178,7 +184,9 @@ jobs:
name: ssh docker watcher isolation
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
# Why 35: the terminal parking + retention specs below add two more
# docker-rig tests capped at 240s each on top of the watcher isolation pair.
timeout-minutes: 35
steps:
- name: Checkout
@@ -221,6 +229,13 @@ jobs:
- name: Run Docker SSH watcher isolation E2E
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
# Why always(): these specs gate the C1 terminal parking/retention budget
# over a real SSH host and run in no other lane, so a watcher-isolation
# failure above must not silently skip them.
- name: Run Docker SSH terminal parking + retention E2E
if: always()
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
- name: Upload watcher isolation traces
if: failure()
uses: actions/upload-artifact@v7
@@ -9,6 +9,8 @@ on:
- 'tests/e2e/fixtures/terminal-emoji-table.md'
- 'tests/e2e/helpers/**'
- 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
- 'tests/e2e/terminal-webgl-atlas-budget.spec.ts'
- 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
- 'src/renderer/src/lib/pane-manager/**'
workflow_dispatch:
+1 -1
View File
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
bump_patch_version:
description: 'Bump the iOS marketing version patch number before release. Tick this after a version has shipped to the App Store (the release fails fast if the current version''s train is already closed).'
description: 'Use the first open iOS patch version after the checked-in version, skipping versions already closed on the App Store.'
required: false
default: false
type: boolean
+9 -1
View File
@@ -30,6 +30,11 @@ jobs:
with:
node-version-file: package.json
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
@@ -51,11 +56,14 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Typecheck
run: npx tsc --noEmit
run: pnpm typecheck
- name: Test
run: pnpm test
- name: Test iOS release version resolution
run: ruby fastlane/ios_release_version_test.rb
- name: Lint
run: pnpm lint
+293 -91
View File
@@ -8,79 +8,67 @@ on:
- reopened
- ready_for_review
concurrency:
group: pr-checks-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
static_analysis:
name: static analysis
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
fetch-depth: 0
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
# Why: pnpm's bundled node-gyp ships gyp_main.py without execute
# permission, which breaks native module builds (e.g. node-pty's
# postinstall) with "/bin/sh: gyp_main.py: Permission denied".
# Pin the fallback to the lockfile's node-gyp version so CI stays
# reproducible while forcing pnpm to bypass its broken bundled copy.
# Gate on runner.os == 'Linux' to match release.yml — the
# npm-global path layout this step assumes is POSIX-shaped, and the
# failure has only been observed on Linux runners. Today this job
# pins runs-on: ubuntu-latest so the guard is a no-op, but it
# prevents a silent break if a Windows/macOS matrix is added later.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Prepare dependency install
run: |
if [ -e node_modules ]; then
ls -ld node_modules
rm -rf node_modules
fi
- name: Install dependencies
# Why: pnpm 10.24's frozen headless fast path can fail on fresh Ubuntu
# runners while creating the root node_modules. Use the normal resolver
# path, then verify package metadata stayed unchanged.
run: |
pnpm install --no-frozen-lockfile --prefer-frozen-lockfile=false
git diff --exit-code package.json pnpm-lock.yaml
- uses: ./.github/actions/install-node-dependencies
- name: Lint
run: pnpm exec oxlint --format github
- name: Enforce focused code-quality plugins
run: pnpm run audit:code-quality:native
- name: Enforce type-aware code-quality baseline
run: pnpm run audit:code-quality:type-aware
- name: Enforce changed-code quality
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
- name: Enforce React Doctor on changed lines
run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}"
- name: Check Zustand selector fan-out budget
run: pnpm run check:zustand-selector-fanout
- name: Check reliability gate manifest
run: pnpm run check:reliability-gates
- name: Enforce max-lines ratchet
run: pnpm run check:max-lines-ratchet
- name: Verify bundled skill guides
run: pnpm run verify:bundled-skill-guides
- name: Verify skill freshness manifest
run: pnpm run verify:skill-bundle-manifest
- name: Verify localization catalog
run: pnpm run verify:localization-catalog
# Why: extraction writes sorted evidence to an isolated temporary path,
# so feature PRs need one normalized AST pass rather than a three-OS matrix.
- name: Verify localization extraction
run: pnpm run verify:localization-extraction
- name: Check styled scrollbars
run: pnpm check:styled-scrollbars
- name: Check reliability gate manifest
run: pnpm run check:reliability-gates
# Why: oxlint fails any file over max-lines that is NOT suppressed, so this
# ratchet forbids ADDING a new suppression (inline disable or mobile max
# bump). Existing oversized files are grandfathered in
# config/max-lines-baseline.txt, which may only shrink — new bypasses fail
# here with a clear message instead of silently growing the debt.
- name: Enforce max-lines ratchet (no new bypasses)
run: pnpm run check:max-lines-ratchet
- name: Verify localization coverage
run: pnpm run verify:localization-coverage
# Why: project-owned type declarations must live in .ts so tsc
# actually checks them. TypeScript's skipLibCheck: true (inherited
@@ -105,55 +93,269 @@ jobs:
- name: Verify macOS entitlements
run: pnpm verify:macos-entitlements
- name: Typecheck
run: pnpm typecheck
typecheck:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- run: pnpm typecheck
git_compatibility:
name: Git compatibility
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
# Why: real old Git diagnostics differ from mocked errors. Keep the
# fallback predicates executable across the baseline, transition, and
# current command shapes so a newly added flag cannot silently regress.
- name: Verify Git binary compatibility matrix
run: |
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
source="$RUNNER_TEMP/git-2.25.5"
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
| sha256sum --check
mkdir -p "$source"
tar -xzf "$archive" -C "$source" --strip-components=1
make -C "$source" -j2 NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
pids=()
(
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
source="$RUNNER_TEMP/git-2.25.5"
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
| sha256sum --check
mkdir -p "$source"
tar -xzf "$archive" -C "$source" --strip-components=1
make -C "$source" -j"$(nproc)" \
NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
) &
pids+=("$!")
for spec in \
"alpine/git:edge-2.38.1|2.38.1" \
"alpine/git:v2.49.1|2.49.1"; do
image="${spec%%|*}"
version="${spec#*|}"
ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
(
image="${spec%%|*}"
version="${spec#*|}"
ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
) &
pids+=("$!")
done
# Why: postinstall rebuilds better-sqlite3 for Electron's ABI via
# @electron/rebuild, but vitest runs under system Node.js. Rebuild
# it for Node so orchestration tests can load the native module.
- name: Rebuild better-sqlite3 for Node
run: pnpm rebuild better-sqlite3
status=0
for pid in "${pids[@]}"; do
wait "$pid" || status=1
done
exit "$status"
shell_contracts:
name: shell contracts
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install zsh
run: sudo apt-get update && sudo apt-get install -y zsh
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- name: Test real shell contracts
run: |
pnpm exec vitest run --config config/vitest.config.ts \
src/main/daemon/shell-ready.test.ts \
src/main/daemon/node-pty-fd-leak.test.ts \
src/main/providers/local-pty-shell-ready.test.ts \
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
src/shared/posix-command-path-lookup.test.ts
test:
name: tests ${{ matrix.shard }}/${{ strategy.job-total }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
# Why: install intentionally blocks Electron's package postinstall, but
# some unit tests import `electron` under Node and require path.txt.
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
- name: Test
run: pnpm test
- name: Test shard
run: |
pnpm exec vitest run --config config/vitest.config.ts \
--exclude=src/main/daemon/shell-ready.test.ts \
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
--exclude=src/main/providers/local-pty-shell-ready.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--shard=${{ matrix.shard }}/${{ strategy.job-total }}
- name: Build unpacked app
run: pnpm build:unpack
package:
name: package
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |
~/.cache/electron
~/.cache/electron-builder
key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-linux-
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
- name: Build package inputs
run: |
status=0
pnpm run build:cli || status=1
scripts=(build:relay build:electron-vite:parallel)
pids=()
for script in "${scripts[@]}"; do
pnpm run "$script" &
pids+=("$!")
done
for pid in "${pids[@]}"; do
wait "$pid" || status=1
done
exit "$status"
- name: Project web client from renderer build
run: pnpm run build:web-from-renderer
- name: Build native components
run: pnpm run build:native
- name: Package unpacked app
env:
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
# Why: the packaged CLI runs outside Electron's asar integration, so
# bare runtime imports must be present in the package itself. Run from a
# temp copy so Node cannot mask missing package deps with repo node_modules.
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
# Why: regression specs under tests/e2e/** used to merge green without ever
# running — e2e.yml only fired on schedule/release (#10518). Path-filter so
# ordinary PRs stay light; any E2E suite change still gets a full shard run.
e2e-paths:
name: detect e2e path changes
runs-on: ubuntu-latest
if: github.event.pull_request.draft != true
# Why: detector only needs to read the checkout; do not inherit repo defaults.
permissions:
contents: read
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- name: Filter E2E-relevant paths
id: filter
run: |
set -euo pipefail
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
# Why: capture first so a failed git diff does not look like "no matches"
# (pipeline status in `if` is not aborted by set -e). Merge-base limits the
# list to files this PR introduced, not base-branch drift.
CHANGED="$(git diff --name-only --merge-base "$BASE" "$HEAD")"
# Why: tests/playwright.config.ts sits beside tests/e2e/, not inside it, so
# it needs its own pattern — a bare `playwright.` prefix matches no tracked
# file and would silently skip E2E when the runner config changes.
if printf '%s\n' "$CHANGED" | grep -E '^(tests/e2e/|tests/playwright\.|\.github/workflows/e2e\.yml$)' >/dev/null; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "E2E path changes detected"
else
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "No E2E path changes"
fi
e2e:
name: e2e
needs: e2e-paths
if: needs.e2e-paths.outputs.should_run == 'true'
# Why: reusable e2e.yml only checkouts, builds, and uploads artifacts.
permissions:
contents: read
uses: ./.github/workflows/e2e.yml
verify:
if: always()
needs:
- static_analysis
- typecheck
- git_compatibility
- shell_contracts
- test
- package
runs-on: ubuntu-latest
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.
- name: Require successful checks
env:
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
TYPECHECK: ${{ needs.typecheck.result }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
TEST: ${{ needs.test.result }}
PACKAGE: ${{ needs.package.result }}
run: |
for result in \
"$STATIC_ANALYSIS" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$PACKAGE"; do
if [ "$result" != "success" ]; then
exit 1
fi
done
+374 -28
View File
@@ -42,7 +42,12 @@ on:
default: false
type: boolean
version_suffix:
description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). rc kind only.
description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). Applies to kind=rc, or to an explicit version that is a bare X.Y.Z-rc.N.
required: false
type: string
default: ''
version:
description: Exact version to cut (e.g. 1.4.155 or 1.4.155-rc.4), bypassing kind-based computation. Use to leapfrog a deleted/rolled-back stable that regressed the release list. Must be greater than the latest published stable, and an -rc.N must be above the highest RC already cut for its own base.
required: false
type: string
default: ''
@@ -68,6 +73,28 @@ jobs:
should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }}
latest_published_rc_tag: ${{ steps.publish_drafts.outputs.latest_published_tag }}
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.
- name: Summarize workflow inputs
if: github.event_name == 'workflow_dispatch'
env:
INPUTS_JSON: ${{ toJSON(inputs) }}
run: |
{
echo "## Workflow inputs"
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.
jq -r '(. // {}) | to_entries[] | "| `\(.key)` | `\(.value | tostring | gsub("\n"; " "))` |"' <<<"$INPUTS_JSON"
} >> "$GITHUB_STEP_SUMMARY"
- name: Checkout ref
uses: actions/checkout@v6
with:
@@ -86,8 +113,17 @@ 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.
INPUT_REF: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }}
REPO: ${{ github.repository }}
SERVER_URL: ${{ github.server_url }}
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
short_sha="$(git rev-parse --short=12 HEAD)"
echo "sha=$sha" >>"$GITHUB_OUTPUT"
# Why: only push the version-bump commit back to main when the
@@ -101,6 +137,97 @@ 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.
input_ref="${INPUT_REF:-main}"
repo_url="${SERVER_URL}/${REPO}"
branches="$(
git for-each-ref --format='%(refname:short)' --points-at="$sha" 'refs/remotes/origin/*' \
| sed 's|^origin/||' \
| grep -vx 'HEAD' \
| sort -u \
|| true
)"
tags="$(
git for-each-ref --format='%(refname:short)' --points-at="$sha" 'refs/tags/*' \
| sort -u \
|| true
)"
# Build comma-separated markdown links. Branch/tag names go in the
# URL path as-is (slashes must stay literal for GitHub tree URLs).
linkify_names() {
local url_kind="$1"
local names="$2"
if [[ -z "${names//[$'\t\r\n']/}" ]]; then
printf '_none_'
return
fi
local first=1
while IFS= read -r name; do
[[ -z "$name" ]] && continue
local path_name url
path_name="${name// /%20}"
case "$url_kind" in
branch) url="${repo_url}/tree/${path_name}" ;;
tag) url="${repo_url}/releases/tag/${path_name}" ;;
*) url="${repo_url}" ;;
esac
if [[ "$first" -eq 1 ]]; then
first=0
else
printf ', '
fi
printf '[`%s`](%s)' "$name" "$url"
done <<<"$names"
}
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`.
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)"
if [[ -n "$approx" ]]; then
# name-rev prints remotes/origin/<branch>[~N]; strip to branch[~N].
approx="${approx#remotes/origin/}"
approx="${approx#origin/}"
contains_md="\`${approx}\`"
fi
fi
input_kind="ref"
if git rev-parse -q --verify "refs/remotes/origin/${input_ref}" >/dev/null 2>&1; then
input_kind="branch"
elif git rev-parse -q --verify "refs/tags/${input_ref}" >/dev/null 2>&1; then
input_kind="tag"
elif [[ "$input_ref" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
input_kind="sha"
fi
{
echo "## Resolved source"
echo ""
echo "Every cut resolves to a commit. Branch/tag rows list refs whose tip is that commit."
echo ""
echo "| Field | Value |"
echo "| --- | --- |"
echo "| Input ref | \`${input_ref}\` (${input_kind}) |"
echo "| Commit | [\`${short_sha}\`](${repo_url}/commit/${sha}) |"
echo "| Branches at commit | ${branch_md} |"
echo "| Tags at commit | ${tag_md} |"
if [[ "$branch_md" == "_none_" ]]; then
echo "| Also on | ${contains_md} |"
fi
echo ""
} >> "$GITHUB_STEP_SUMMARY"
- name: Compute RC slot
id: slot
run: |
@@ -198,6 +325,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }}
VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }}
EXPLICIT_VERSION: ${{ github.event_name == 'schedule' && '' || inputs.version }}
run: |
set -euo pipefail
@@ -260,6 +388,23 @@ jobs:
node config/scripts/release-rc-history.mjs "$1"
}
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.
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
fi
}
current_package_stable() {
node -e '
const { version } = require("./package.json");
@@ -336,7 +481,14 @@ jobs:
# floor for the current ref so the next cut cannot reuse an older
# stable number just because the public release was nuked.
if semver_gt "$package_stable" "$latest_stable"; then
if [[ "$KIND" != "rc" ]]; then
# 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.
if [[ "$KIND" != "rc" && -z "${EXPLICIT_VERSION:-}" ]]; then
package_tag="v$package_stable"
if git rev-parse "$package_tag" >/dev/null 2>&1; then
recover_unpublished_tag "$package_tag" "current ref stable tag is newer than latest published stable" || true
@@ -348,6 +500,98 @@ 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.
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.
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.
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).
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.
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.
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.
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
# Same bounded rc pattern as the shape check above, so the two
# cannot drift apart under a later edit.
if [[ ! "$explicit" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.(0|[1-9][0-9]{0,8})$ ]]; then
echo "::error::version_suffix applies only to a bare X.Y.Z-rc.N version, got: $explicit" >&2
exit 1
fi
require_valid_version_suffix "$VERSION_SUFFIX"
new="${new}.${VERSION_SUFFIX}"
fi
echo "Explicit version override: $new"
fi
if [[ -z "$new" ]]; then
case "$KIND" in
rc)
# Why: RCs always stabilize the *next* patch after whatever
@@ -373,15 +617,7 @@ jobs:
new="${base}-rc.$((highest_rc + 1))"
fi
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
# Why a dot-appended identifier (rc.N.perf): it sorts just
# above its own base rc.N but BELOW rc.N+1, so suffixed side-
# branch builds never outrank the main RC series and cannot
# hijack the update channel; clients find them by matching the
# identifier ("perf") in the prerelease components.
if [[ ! "$VERSION_SUFFIX" =~ ^[0-9A-Za-z]+$ ]]; then
echo "::error::version_suffix must be alphanumeric, got: $VERSION_SUFFIX" >&2
exit 1
fi
require_valid_version_suffix "$VERSION_SUFFIX"
new="${new}.${VERSION_SUFFIX}"
fi
;;
@@ -425,6 +661,7 @@ jobs:
exit 1
;;
esac
fi
# Orphan-tag recovery.
#
@@ -472,7 +709,19 @@ jobs:
# message and tag name explicitly (avoids npm's `v1.2.3` prefix
# assumptions and any lifecycle scripts that would run on bump).
npm version "$VERSION" --no-git-tag-version --allow-same-version
git add package.json
# Why: the cut is the only point where committed skill bytes become a
# released revision. Without this row the ledger never advances, so the
# next skill change rebuilds the revision this tag ships over different
# bytes and every install of it stops matching a known snapshot.
# --release is provenance-only: it fails if the content-addressed
# artifacts do not already match this ref and writes just the mapping
# row, so the version commit stays skill-independent. Node built-ins
# only, so this needs no install.
if ! node config/scripts/generate-skill-bundle-manifest.mjs --release "$VERSION"; then
echo "::error::Refusing to record release provenance for v$VERSION: the committed skill artifacts do not match this ref. Land a regeneration on main, then re-run the cut." >&2
exit 1
fi
git add package.json resources/skills/release-mapping.json
commit_message="release: v$VERSION"
if [[ "$EVENT_NAME" == "schedule" ]]; then
commit_message="$commit_message [rc-slot:$SLOT]"
@@ -485,6 +734,22 @@ jobs:
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 and the provenance row, 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.
committed="$(git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD |
grep -vxF -e 'package.json' -e 'resources/skills/release-mapping.json' || true)"
if [[ -n "$committed" ]]; then
echo "::error::Release commit carries unexpected paths: $(echo "$committed" | tr '\n' ' ')Only package.json and the skill release-mapping row may ship in a version commit." >&2
exit 1
fi
git tag -a "v$VERSION" -m "v$VERSION"
echo "tag=v$VERSION" >>"$GITHUB_OUTPUT"
echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
@@ -512,7 +777,7 @@ jobs:
echo "## Release E2E Signal"
echo ""
echo "- Terminal rendering golden is release-blocking."
echo "- Full E2E is diagnostic/non-blocking release evidence."
echo "- Full E2E runs separately after publication and cannot change the release result."
echo "- Terminal rendering release evidence is diagnostic/non-blocking."
echo ""
echo "Publishing behavior is controlled by the existing job dependencies; this summary does not change release gating."
@@ -542,15 +807,6 @@ jobs:
node config/scripts/create-draft-release.mjs "$TAG"
# Why: tag-scoped E2E gives release visibility, but the suite is flaky enough
# that publish-release must not depend on it.
e2e:
needs: cut
if: needs.cut.outputs.should_release == 'true'
uses: ./.github/workflows/e2e.yml
with:
ref: refs/tags/${{ needs.cut.outputs.tag }}
terminal-rendering-golden:
needs: cut
if: needs.cut.outputs.should_release == 'true'
@@ -1234,7 +1490,6 @@ jobs:
Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.'
}
# ── End Windows inner-binary signing ───────────────────────────────
- name: Upload unsigned Windows installer for SignPath
if: matrix.platform == 'win'
id: upload-unsigned-windows-installer
@@ -1326,7 +1581,8 @@ jobs:
}
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
node config/scripts/generate-windows-blockmap.mjs 'dist/orca-windows-setup.exe' 'dist/orca-windows-setup.exe.blockmap'
if ($LASTEXITCODE -ne 0) { throw "blockmap generation failed with exit code $LASTEXITCODE" }
$installer = Get-Item 'dist/orca-windows-setup.exe'
$blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap'
@@ -1378,8 +1634,41 @@ jobs:
INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }}
run: |
$required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true'
# Why: a fail-open gate that writes nothing is indistinguishable from a
# gate that passed. Always leave a verdict in the evidence artifact and
# the job summary so a silent degradation is visible (#6487).
# Why best-effort: while warn-only, a disk-full or permission error
# writing the verdict must not become the thing that fails the release.
function Add-GateEvidence([string]$line) {
try {
Add-Content -Path 'inner-signing-evidence.txt' -Value "`n$line" -ErrorAction Stop
} catch {
Write-Host "::warning::Could not append to the inner-signing evidence file: $_"
}
}
function Add-GateSummary([string]$verdict) {
if (-not $env:GITHUB_STEP_SUMMARY) { return }
try {
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "**Windows inner-binary signing:** $verdict" -ErrorAction Stop
} catch {
Write-Host "::warning::Could not write inner-signing verdict to the job summary: $_"
}
}
function Write-GateVerdict([string]$verdict) {
try {
Set-Content -Path 'inner-signing-evidence.txt' -Value $verdict -ErrorAction Stop
} catch {
Write-Host "::warning::Could not persist inner-signing verdict: $_"
}
Add-GateSummary $verdict
}
if ($env:INNER_SIGNING_COMPLETED -ne 'true') {
$message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).'
Write-GateVerdict "NOT VERIFIED — $message"
if ($required) { throw $message }
Write-Host "::warning::$message"
exit 0
@@ -1388,13 +1677,28 @@ jobs:
# Why try/catch: while the gate is warn-only, even an unexpected
# script error (extraction hiccup, missing file) must not block
# the release — only the flip to required makes failures fatal.
# Why tracked separately: a required-mode signature failure must not be
# rewritten as ERRORED by the catch below, which would replace the
# per-file report with an exception string and lose the diagnostics.
$policyFailure = $null
try {
$report = New-Object System.Collections.Generic.List[string]
$failures = New-Object System.Collections.Generic.List[string]
# Why: verify the files a user actually gets on disk, not the build
# tree — 7z parses the NSIS exe directly as its embedded payload.
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
# Resolve 7za via app-builder-lib; electron-builder 26.9+ dropped the
# bundled 7zip-bin package the old hardcoded path relied on (#6487).
$7zaOutput = node config/scripts/resolve-7za-path.mjs
$7zaExitCode = $LASTEXITCODE
if ($7zaExitCode -ne 0) {
throw "The 7za resolver exited with code $7zaExitCode for the inner-binary evidence gate."
}
$7za = ($7zaOutput | Out-String).Trim()
if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf)) {
throw "The 7za resolver returned an invalid path for the inner-binary evidence gate: $7za"
}
New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null
& $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null
@@ -1428,16 +1732,32 @@ jobs:
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::warning::$_" }
$message = "Windows inner-binary evidence gate found $($failures.Count) problems."
if ($required) { throw $message }
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
# 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.
if ($required) {
$policyFailure = $message
} else {
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
}
Add-GateEvidence "VERDICT: FAILED — $message"
Add-GateSummary "FAILED — $message"
} else {
Write-Host "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
$ok = "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
Add-GateEvidence "VERDICT: PASSED — $ok"
Add-GateSummary "PASSED — $ok"
Write-Host $ok
}
} catch {
Write-GateVerdict "ERRORED — $_"
if ($required) { throw }
Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)."
}
# Outside the catch so the FAILED evidence report survives intact.
if ($policyFailure) { throw $policyFailure }
- name: Upload Windows inner signing evidence
if: always() && matrix.platform == 'win'
uses: actions/upload-artifact@v7
@@ -1601,6 +1921,32 @@ jobs:
--prerelease="$prerelease" \
--repo "$GITHUB_REPOSITORY"
post-release-e2e:
needs:
- cut
- publish-release
if: ${{ needs.cut.outputs.tag != '' }}
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Dispatch tag-scoped E2E
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.cut.outputs.tag }}
run: |
for attempt in 1 2 3; do
if gh workflow run e2e.yml \
--repo "$GITHUB_REPOSITORY" \
--ref "$TAG" \
--raw-field "ref=refs/tags/$TAG"; then
echo "Dispatched post-release E2E for $TAG."
exit 0
fi
[[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))"
done
echo "::warning::Failed to dispatch post-release E2E for $TAG after 3 attempts."
homebrew-bump-published-rc-draft:
needs:
- cut
@@ -0,0 +1,50 @@
name: Skill update round trip
on:
pull_request:
paths:
- 'skills/**'
- 'resources/skills/**'
- 'config/scripts/verify-skill-update-roundtrip.mjs'
- 'src/main/skills/skill-freshness-eligibility.ts'
- 'src/shared/skill-freshness.ts'
- '.github/workflows/skill-update-roundtrip.yml'
merge_group:
push:
branches:
- main
jobs:
roundtrip:
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
shape: [symlink, copy]
autocrlf: ['false', 'true']
skills-cli: ['1.5.17']
include:
- os: ubuntu-latest
shape: symlink
autocrlf: 'false'
skills-cli: latest
continue-on-error: ${{ matrix.skills-cli == 'latest' }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Verify targeted update convergence and copy behavior
env:
GITHUB_TOKEN: ${{ github.token }}
SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
SKILL_UPDATE_REF: ${{ github.head_ref || github.ref_name }}
run: >-
node config/scripts/verify-skill-update-roundtrip.mjs
--cli=${{ matrix.skills-cli }}
--autocrlf=${{ matrix.autocrlf }}
--shape=${{ matrix.shape }}
@@ -0,0 +1,199 @@
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
# pull requests keep it as a durable regression gate. A CI runner is the only
# safe place to install — see the relocation post-mortem.
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- '.github/workflows/win-crash-survival-e2e.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'electron.vite.config.ts'
- 'build-plugins/**'
- 'config/electron-builder.config.cjs'
- 'config/patches/**'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/rebuild-native-deps.mjs'
- 'config/scripts/verify-packaged-plugin-resources.cjs'
- 'native/**'
# Byte-hashed at package time, and CRLF-sensitive on Windows.
- '.gitattributes'
- 'resources/plugins/**'
- 'resources/win32/**'
- 'src/main/daemon/**'
- 'src/main/index.ts'
- 'src/main/ipc/pty*.ts'
- 'src/main/persistence.ts'
- 'src/main/providers/**'
- 'src/main/pty/**'
- 'src/main/startup/first-window-startup-services.ts'
- 'src/main/window/attach-main-window-services.ts'
- 'src/preload/**'
- 'src/renderer/src/App.tsx'
- 'src/renderer/src/components/terminal-pane/**'
- 'src/renderer/src/hooks/useIpcEvents.ts'
- 'src/renderer/src/lib/pane-manager/**'
- 'src/renderer/src/lib/session-write-subscriber.ts'
- 'src/renderer/src/lib/workspace-session-host-persistence.ts'
- 'src/renderer/src/store/slices/terminals.ts'
- 'src/shared/pty-session-id-format.ts'
- 'tools/win-crash-survival-e2e/**'
- 'tools/win-update-e2e/**'
# Why: source tests and benchmarks do not change the packaged artifact;
# their own verify jobs cover them without spending a Windows build slot.
- '!src/**/*.test.*'
- '!src/**/*.bench.*'
workflow_dispatch:
inputs:
expect:
description: Expected outcome profile
required: true
type: choice
default: survival
options:
- survival
- orphaned
permissions:
contents: read
concurrency:
group: win-crash-survival-e2e-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
crash-survival:
name: crash-survival (packaged build)
runs-on: windows-2022
timeout-minutes: 50
env:
EXPECT: ${{ inputs.expect || 'survival' }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# This job only builds and runs the harness locally; it never pushes.
persist-credentials: false
# Why: setup-node can restore pnpm's content-addressed store only after
# the pnpm binary exists, avoiding repeat dependency downloads per run.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why: cache the built installer by production build inputs, excluding
# tests/reliability metadata so harness-only edits skip electron-builder.
# The daemon relocation lives under src/, so product changes still rebuild.
- name: Cache branch installer
id: cache-installer
uses: actions/cache@v4
with:
path: dist/orca-windows-setup.exe
key: >-
crash-survival-installer-${{ hashFiles(
'src/**',
'!src/**/*.test.*',
'!src/**/*.bench.*',
'config/**',
'!config/**/*.test.*',
'!config/reliability-gates.jsonc',
'!config/max-lines-baseline.txt',
'!config/vitest.config.ts',
'build-plugins/**',
'native/**',
'resources/**',
'electron.vite.config.ts',
'tsconfig.json',
'vite.web.config.ts',
'.npmrc',
'package.json',
'pnpm-lock.yaml',
'pnpm-workspace.yaml'
) }}
# Why: production edits miss the installer cache by design, but Electron
# and NSIS downloads are lockfile-owned and need not be fetched again.
- name: Cache electron-builder downloads
if: steps.cache-installer.outputs.cache-hit != 'true'
uses: actions/cache@v5
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: crash-survival-electron-builder-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
crash-survival-electron-builder-
- name: Build Windows installer (unsigned)
if: steps.cache-installer.outputs.cache-hit != 'true'
run: |
node config/scripts/ensure-native-runtime.mjs --runtime=electron
pnpm run build:desktop
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never
# Why: silent-install the branch build so the crash harness has a packaged
# Orca.exe to drive. A clean CI runner installs to the default per-user
# location (%LOCALAPPDATA%\Programs\Orca), which the harness auto-locates.
- name: Silent-install branch build
shell: pwsh
run: |
$exe = "dist/orca-windows-setup.exe"
if (-not (Test-Path $exe)) { throw "Installer not found at $exe" }
Start-Process -FilePath $exe -ArgumentList '/S' -Wait
$installed = Join-Path $env:LOCALAPPDATA 'Programs/Orca/Orca.exe'
if (-not (Test-Path $installed)) {
$installed = Join-Path $env:LOCALAPPDATA 'Programs/orca/Orca.exe'
}
if (-not (Test-Path $installed)) { throw "Installed Orca.exe not found after silent install" }
"ORCA_EXE=$installed" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# Why: crash ONLY the app main and assert the relocated daemon + its shell
# survive, then that relaunch adopts them and input causes no pwsh FailFast.
- name: Run crash-survival harness
id: harness
shell: pwsh
env:
ORCA_E2E_DIAG_DIR: artifacts/diag
run: |
New-Item -ItemType Directory -Force artifacts | Out-Null
$log = "artifacts/crash-survival-output.log"
node tools/win-crash-survival-e2e/run.mjs `
--expect "$env:EXPECT" `
--exe-path "$env:ORCA_EXE" `
--soak-seconds 8 2>&1 | Tee-Object -FilePath $log
exit $LASTEXITCODE
- name: Upload crash-survival output
if: always()
uses: actions/upload-artifact@v7
with:
name: win-crash-survival-output
path: |
artifacts/crash-survival-output.log
artifacts/diag/**
retention-days: 7
if-no-files-found: warn
+1
View File
@@ -128,6 +128,7 @@ jobs:
node tools/win-update-e2e/run.mjs `
--from "$env:FROM_EXE" `
--to "$env:TO_EXE" `
--require-distinct-artifacts `
--expect "$env:EXPECT" `
--soak-seconds "$env:SOAK_SECONDS" 2>&1 | Tee-Object -FilePath $log
exit $LASTEXITCODE
@@ -271,7 +271,7 @@ jobs:
throw 'Signed Windows installer was not returned by SignPath.'
}
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
node config/scripts/generate-windows-blockmap.mjs 'dist/orca-windows-setup.exe' 'dist/orca-windows-setup.exe.blockmap'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$installer = Get-Item 'dist/orca-windows-setup.exe'
@@ -325,7 +325,16 @@ jobs:
# Extract the signed installer and verify the files a user actually
# gets on disk — including the exact file from issue #7785.
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
# electron-builder 26.9+ removed the bundled 7zip-bin package (#6487).
$7zaOutput = node config/scripts/resolve-7za-path.mjs
$7zaExitCode = $LASTEXITCODE
if ($7zaExitCode -ne 0) {
throw "The 7za resolver exited with code $7zaExitCode for the signing rehearsal."
}
$7za = ($7zaOutput | Out-String).Trim()
if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf)) {
throw "The 7za resolver returned an invalid path for the signing rehearsal: $7za"
}
New-Item -ItemType Directory -Path extracted-app -Force | Out-Null
& $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null
@@ -0,0 +1,107 @@
name: Windows terminal restart E2E
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- '.github/workflows/windows-terminal-restart-e2e.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'electron.vite.config.ts'
- 'config/patches/**'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/rebuild-native-deps.mjs'
- 'tests/playwright.config.ts'
- 'tests/e2e/global-setup.ts'
- 'tests/e2e/global-teardown.ts'
- 'tests/e2e/helpers/**'
- 'tests/e2e/restart-restore-terminal-input.spec.ts'
- 'tests/e2e/restored-terminal-input-readiness.unit.test.ts'
- 'tests/e2e/terminal-probe-input-sequence.ts'
- 'tests/e2e/terminal-probe-input-sequence.unit.test.ts'
- 'tests/e2e/terminal-restart-persistence.spec.ts'
- 'src/main/daemon/**'
- 'src/main/ipc/pty*.ts'
- 'src/main/providers/**'
- 'src/main/pty/**'
- 'src/main/pty-descendant-termination.ts'
- 'src/main/windows-process-tree-kill.ts'
- 'src/main/windows-pty-root-identity.ts'
- 'src/preload/**'
- 'src/renderer/src/components/terminal-pane/**'
- 'src/renderer/src/lib/pane-manager/**'
- 'src/shared/process-table-snapshot.ts'
- 'src/shared/pty-session-id-format.ts'
workflow_dispatch:
inputs:
ref:
description: Ref to check out
required: false
type: string
permissions:
contents: read
concurrency:
group: windows-terminal-restart-e2e-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
windows-terminal-restart:
name: Windows terminal restart regressions
runs-on: windows-2022
timeout-minutes: 30
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
persist-credentials: false
# Why: pnpm must exist before setup-node resolves its dependency cache.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Electron app for Windows E2E
run: pnpm exec electron-vite build --mode e2e
- name: Run Windows terminal restart regressions
env:
SKIP_BUILD: '1'
ORCA_E2E_FORWARD_APP_LOGS: '1'
ORCA_REQUIRE_WINDOWS_TERMINAL_RESTART_E2E: '1'
# Why: pnpm forwards a literal `--` to Playwright, which makes the
# grep and worker flags positional filters instead of CLI options.
run: >-
pnpm run test:e2e
tests/e2e/restart-restore-terminal-input.spec.ts
tests/e2e/terminal-restart-persistence.spec.ts
--grep "clean restart with a live daemon session|cold-restored pane accepts typing|daemon snapshot relaunch preserves"
--workers=1
- name: Upload Playwright traces
if: failure()
uses: actions/upload-artifact@v7
with:
name: windows-terminal-restart-playwright-traces
path: test-results/
retention-days: 7
if-no-files-found: ignore