name: PR Checks on: pull_request: types: - opened - synchronize - reopened - ready_for_review concurrency: group: pr-checks-${{ github.event.pull_request.number }} cancel-in-progress: true permissions: contents: read jobs: static_analysis: name: static analysis runs-on: ubuntu-latest steps: - 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. fetch-depth: 0 filter: blob:none persist-credentials: false - 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: Check VM runtime rollback compatibility env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | if git diff --quiet --merge-base "$BASE_SHA" "$HEAD_SHA" -- \ src/shared/ephemeral-vm-runtime-store.ts \ src/shared/ephemeral-vm-runtime-feature-store.ts \ src/shared/ephemeral-vm-runtime-rollback-projection.ts \ src/shared/ephemeral-vm-runtimes.ts \ src/shared/ephemeral-vm-recipes.ts \ src/shared/orca-yaml-hook-types.ts \ src/main/ephemeral-vm-runtime-service.ts \ src/main/ephemeral-vm-runtime-provisioning-persistence.ts \ src/main/ephemeral-vm-failed-start-cleanup.ts; then echo "VM runtime persistence is unchanged." exit 0 fi node config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs \ config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts - 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: 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 # 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. - name: Guard against project-owned .d.ts in preload/shared run: | matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true) if [ -n "$matches" ]; then echo "::error::Project-owned .d.ts files are not allowed under src/preload or src/shared." echo "Move type declarations into a .ts file so skipLibCheck does not hide errors." echo "See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts." echo "Found:" echo "$matches" exit 1 fi - name: Check feature wall asset budget run: pnpm check:feature-wall-assets - name: Verify macOS entitlements run: pnpm verify:macos-entitlements root_directory_guard: name: root directory guard runs-on: ubuntu-latest steps: - 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. fetch-depth: 0 filter: blob:none persist-credentials: false - name: Reject new root-level files and folders env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA" typecheck: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 with: persist-credentials: false - uses: ./.github/actions/install-node-dependencies # Why: every project is `composite`, so tsc already writes a .tsbuildinfo that lets # the next run skip unchanged files. CI threw it away each time. The key is per-SHA # so each run saves its own; restore-keys inherit the newest prior graph to diff against. - name: Cache TypeScript incremental state uses: actions/cache@v5 with: path: config/*.tsbuildinfo key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.sha }} restore-keys: | tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}- tsbuildinfo-${{ runner.os }}- - run: pnpm run 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: the 2.25.5 lane is a source build of a pinned tarball, so it produced the # same binary on every PR for minutes of runner time. The key carries the version # because that is the only input; the sha256 assertion below still guards the # tarball on the miss path that actually builds. - name: Cache baseline Git build uses: actions/cache@v5 with: path: ~/.cache/orca-git-compat/git-2.25.5 key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5 - name: Verify Git binary compatibility matrix run: | pids=() ( archive="$RUNNER_TEMP/git-2.25.5.tar.gz" source="$HOME/.cache/orca-git-compat/git-2.25.5" if [ ! -x "$source/git" ]; then 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 # Why: the linked binaries are what the next run needs; the objects that # produced them are most of the tree and would bloat the cache entry. find "$source" -name '*.o' -delete fi 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 ) & pids+=("$!") done status=0 for pid in "${pids[@]}"; do wait "$pid" || status=1 done exit "$status" xterm_patch_sync: name: xterm patch sync runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 with: persist-credentials: false - uses: ./.github/actions/install-node-dependencies # Why: the check rebuilds xterm.js from a pinned upstream commit. Caching the # npm metadata and the shallow clone turns a ~4 min cold run into well under a # minute; the key is the manifest, so a commit or toolchain bump invalidates it. - name: Restore upstream xterm build inputs uses: actions/cache@v5 with: path: | ~/.npm ${{ runner.temp }}/xterm-patch-build/upstream/.git key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }} - name: Verify xterm patches match the pinned upstream build env: WORK_DIR: ${{ runner.temp }}/xterm-patch-build run: node config/scripts/regenerate-xterm-patches.mjs --check --work-dir="$WORK_DIR" shell_contracts: name: shell contracts runs-on: ubuntu-latest 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. ORCA_REQUIRE_FISH: '1' steps: - name: Checkout uses: actions/checkout@v6 with: persist-credentials: false # Why fish: shell-ready.test.ts gates its live fish test on the binary being # present, so without this the fish barrier is only covered by config-shape # assertions and never actually exercised. # Why release-4: DECSET 2031 arming lives in the fish 4.0 Rust tty_handoff, and # fish-color-scheme-child-stdin.node-pty.test.ts (#9993) needs it. Noble ships # 3.7, so the PPA is what makes that lane real. - name: Install zsh and fish run: | # Why the update/PPA/fish steps are tolerant: a repo the runner image already # ships can lack a Release file for this suite, and a failed add-apt-repository # still leaves its list entry behind — either makes `apt-get update` exit # non-zero and would red this required check over something unrelated to the # PR. Every fish outcome is judged by the version gate below instead, so only # the zsh install (which has no such gate) stays fatal here. # Why retry only here: adding the PPA is the network-flaky step, and the # version gate below is fatal, so a transient Launchpad blip would # otherwise red a required check on PRs unrelated to shells. # Why -n: add-apt-repository refreshes every configured repo on its own. With # an update on each side of it this step refreshed them three times over, and # the Azure archive mirror alone costs ~15-30s a pass. The PPA index is the # only thing the repo list gains here, and the single update below fetches it. for attempt in 1 2 3; do sudo add-apt-repository -y -n ppa:fish-shell/release-4 && break echo "add-apt-repository attempt ${attempt} failed; retrying" >&2 sudo add-apt-repository -y -n -r ppa:fish-shell/release-4 || true sleep 5 done sudo apt-get update || true # Why both shells on one line: pr-workflow-parallelism.test.mjs parses only the # first install command in this step to prove the lane really installs them. sudo apt-get install -y zsh fish # 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. - name: Require fish 4+ run: | version="$(fish --version 2>/dev/null || true)" major="${version##*version }" major="${major%%.*}" case "$major" in '' | *[!0-9]*) major=0 ;; esac echo "${version:-}" if [ "$major" -lt 4 ]; then echo "::error::shell contracts needs fish 4+ (DECSET 2031 arming, #9993) but got '${version:-none}'. Fix the ppa:fish-shell/release-4 install rather than letting the fish lane skip." >&2 exit 1 fi - 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 --maxWorkers=1 \ src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ src/main/daemon/shell-ready.test.ts \ src/main/daemon/node-pty-fd-leak.test.ts \ src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts \ src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts \ src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \ src/main/providers/__tests__/shell-ready-framework-example.test.ts \ src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/main/terminal-history-fish-session.node-pty.test.ts \ src/main/zsh-scoped-histfile.live-shell.test.ts \ src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ src/shared/startup-shell-portability.live-shell.test.ts \ src/shared/posix-command-path-lookup.test.ts test: name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: node: ['24', '26'] shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] shard_total: [16] steps: - name: Checkout uses: actions/checkout@v6 with: persist-credentials: false - uses: ./.github/actions/install-node-dependencies with: native-runtime: node node-version: ${{ matrix.node }} - name: Install Electron package binary for tests run: node config/scripts/install-electron-package-binary.mjs - name: Test shard run: | pnpm exec vitest run --config config/vitest.config.ts \ --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.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-zsh-launch-environment.test.ts \ --exclude=src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts \ --exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts \ --exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.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/main/terminal-history-fish-session.node-pty.test.ts \ --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ --exclude=src/shared/posix-command-path-lookup.test.ts \ --exclude=tests/e2e/cross-version-wire/** \ --shard=${{ matrix.shard }}/${{ matrix.shard_total }} cross-version-wire: name: cross-version wire compatibility runs-on: ubuntu-latest 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. - 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. fetch-depth: 0 filter: blob:none persist-credentials: false - uses: ./.github/actions/install-node-dependencies with: native-runtime: node # A path filter that matches nothing exits 1 ("No test files found"), so this # lane cannot report success while running zero tests. - name: Old/new client and server terminal journey run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts managed_hook_node18: name: managed hooks on Node 18 runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 with: persist-credentials: false - uses: ./.github/actions/install-node-dependencies - name: Build relay companions run: pnpm run build:relay - name: Setup Node 18 runtime uses: actions/setup-node@v6 with: node-version: '18' - name: Smoke managed-hook companions run: node config/scripts/smoke-managed-hook-runtime-node18.mjs 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 --linux AppImage --x64 --publish never - name: Verify headless serve signal shutdown run: node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage - name: Smoke packaged CLI run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked - name: Smoke packaged hang watchdog worker run: xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked package_windows: name: package (windows) runs-on: windows-2022 timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v6 with: persist-credentials: false - 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: Cache electron-builder downloads uses: actions/cache@v5 with: path: | ~\AppData\Local\electron\Cache ~\AppData\Local\electron-builder\Cache key: electron-builder-windows-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | electron-builder-windows- - name: Install dependencies run: pnpm install --frozen-lockfile - name: Test Windows-specific boundaries run: >- pnpm exec vitest run --config config/vitest.config.ts src/main/cli/wsl-cli-powershell-boundary.test.ts src/main/orca-profiles/profile-index-store.test.ts src/main/runtime/repo-worktree-admin-fingerprint.test.ts src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts src/shared/secure-file-fsync-flags.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. - name: Build package inputs run: pnpm run build:release:parallel - name: Prepare Electron native runtime run: node config/scripts/ensure-native-runtime.mjs --runtime=electron - name: Package unpacked app env: ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1' run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir - name: Smoke packaged CLI run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked # Why: PR E2E is advisory and only validates changed specs; scheduled and # release runs retain full-suite coverage. e2e-paths: name: detect changed e2e specs 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 }} test_files: ${{ steps.filter.outputs.test_files }} steps: - 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. fetch-depth: 0 filter: blob:none persist-credentials: false - name: Filter changed E2E specs id: filter run: | set -euo pipefail BASE="${{ github.event.pull_request.base.sha }}" HEAD="${{ github.event.pull_request.head.sha }}" CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")" TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)" if printf '%s\n' "$CHANGED" | grep -Eq '^src/(main/ephemeral-vm-(runtime-(service|provisioning-persistence)|failed-start-cleanup)|shared/(ephemeral-vm-runtime-(store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$'; then TEST_FILES="$(printf '%s\n%s\n' "$TEST_FILES" 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' | sort -u)" fi # Why: the Docker-SSH specs only ever ran when someone edited a spec, so four # pane-restore regressions shipped from SSH source edits that touched no test. # Unit tests are excluded because they prove the same source without Docker. # ssh-startup-exec-readiness.spec.ts is listed to route e2e.yml's dedicated # ssh-docker-watcher-isolation lane, which filters it out of changed-e2e. if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(src/main/ssh/|src/main/providers/ssh-|src/main/ipc/(ssh-|pty)|src/relay/|src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty))'; then TEST_FILES="$(printf '%s\n' "$TEST_FILES" \ 'tests/e2e/pty-input-write-queue-ssh.spec.ts' \ 'tests/e2e/ssh-cold-activation-restore.spec.ts' \ 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts' \ 'tests/e2e/ssh-startup-exec-readiness.spec.ts' \ 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' | sort -u)" fi TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')" echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT" if [ "$TEST_FILES_JSON" != '[]' ]; then echo "should_run=true" >> "$GITHUB_OUTPUT" echo "Changed E2E specs: $TEST_FILES_JSON" else echo "should_run=false" >> "$GITHUB_OUTPUT" echo "No changed E2E specs" 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 with: test_files: ${{ needs.e2e-paths.outputs.test_files }} verify: if: always() needs: - static_analysis - root_directory_guard - typecheck - git_compatibility - xterm_patch_sync - shell_contracts - test - managed_hook_node18 - package - package_windows 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 }} ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }} TYPECHECK: ${{ needs.typecheck.result }} GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }} SHELL_CONTRACTS: ${{ needs.shell_contracts.result }} TEST: ${{ needs.test.result }} MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }} PACKAGE: ${{ needs.package.result }} PACKAGE_WINDOWS: ${{ needs.package_windows.result }} run: | for result in \ "$STATIC_ANALYSIS" \ "$ROOT_DIRECTORY_GUARD" \ "$TYPECHECK" \ "$GIT_COMPATIBILITY" \ "$XTERM_PATCH_SYNC" \ "$SHELL_CONTRACTS" \ "$TEST" \ "$MANAGED_HOOK_NODE18" \ "$PACKAGE" \ "$PACKAGE_WINDOWS"; do if [ "$result" != "success" ]; then exit 1 fi done