From d371e2d8b6bb3ba4f4ae4f3db2b4af4e752d73cc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:53:57 -0700 Subject: [PATCH] ci: reduce runner occupancy in release and contract jobs (#28930) --- .github/workflows/release-cut.yml | 48 +++- .github/workflows/zcode-account-contract.yml | 31 +-- .../workflows/zcode-credential-contract.yml | 58 ++++- ...package-electron-runtime-contract.test.mjs | 70 ++++- .../release-cut-token-permissions.test.mjs | 1 + ...lease-mac-build-workflow-dispatch.test.mjs | 208 +++++++++++++++ .../run-release-mac-build-workflow.mjs | 130 +++++++++- .../zcode-credential-workflow.test.mjs | 244 ++++++++++++++++++ tests/e2e/helpers/orcad-convert-teardown.ts | 57 ++++ .../orcad-convert-teardown.unit.test.ts | 131 ++++++++++ tests/e2e/ssh-orcad-auto-convert.spec.ts | 32 ++- 11 files changed, 965 insertions(+), 45 deletions(-) create mode 100644 config/scripts/zcode-credential-workflow.test.mjs create mode 100644 tests/e2e/helpers/orcad-convert-teardown.ts create mode 100644 tests/e2e/helpers/orcad-convert-teardown.unit.test.ts diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 5875e99f8eb..77f3133db3c 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -2336,6 +2336,11 @@ jobs: permissions: actions: write contents: read + outputs: + run_id: ${{ steps.dispatch.outputs.run_id }} + workflow_sha: ${{ steps.dispatch.outputs.workflow_sha }} + run_attempt: ${{ steps.dispatch.outputs.run_attempt }} + wait_deadline_ms: ${{ steps.dispatch.outputs.wait_deadline_ms }} steps: - name: Checkout uses: actions/checkout@v6 @@ -2346,7 +2351,8 @@ jobs: node-version-file: package.json - name: Run isolated macOS release build - run: node config/scripts/run-release-mac-build-workflow.mjs + id: dispatch + run: node config/scripts/run-release-mac-build-workflow.mjs dispatch env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_MAC_BUILD_REF: ${{ github.ref_name }} @@ -2355,11 +2361,50 @@ jobs: RELEASE_MAC_BUILD_TAG: ${{ needs.cut.outputs.tag }} RELEASE_MAC_BUILD_WORKFLOW: release-mac-build.yml + verify-mac-build: + needs: + - cut + - build-mac + - build + # Qualify macOS even when a sibling build fails; publication still requires every build. + if: >- + !cancelled() && + needs.cut.result == 'success' && + needs.cut.outputs.should_release == 'true' && + needs.build-mac.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 95 + permissions: + actions: read + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Require the exact isolated macOS release build to succeed + run: node config/scripts/run-release-mac-build-workflow.mjs wait + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_MAC_BUILD_REF: ${{ github.ref_name }} + RELEASE_MAC_BUILD_RELEASE_RUN_ID: ${{ github.run_id }} + RELEASE_MAC_BUILD_RUN_ID: ${{ needs.build-mac.outputs.run_id }} + RELEASE_MAC_BUILD_WORKFLOW_SHA: ${{ needs.build-mac.outputs.workflow_sha }} + RELEASE_MAC_BUILD_RUN_ATTEMPT: ${{ needs.build-mac.outputs.run_attempt }} + RELEASE_MAC_BUILD_WAIT_DEADLINE_MS: ${{ needs.build-mac.outputs.wait_deadline_ms }} + RELEASE_MAC_BUILD_TAG: ${{ needs.cut.outputs.tag }} + RELEASE_MAC_BUILD_WORKFLOW: release-mac-build.yml + publish-release: needs: - cut - build - build-mac + - verify-mac-build - skill-sharing-linux-floor-release-gate - skill-sharing-release-gate - terminal-rendering-golden @@ -2369,6 +2414,7 @@ jobs: needs.cut.result == 'success' && needs.build.result == 'success' && needs.build-mac.result == 'success' && + needs.verify-mac-build.result == 'success' && needs.skill-sharing-linux-floor-release-gate.result == 'success' && needs.skill-sharing-release-gate.result == 'success' && needs.terminal-rendering-golden.result == 'success' diff --git a/.github/workflows/zcode-account-contract.yml b/.github/workflows/zcode-account-contract.yml index 082648142f2..c48cbc4c07b 100644 --- a/.github/workflows/zcode-account-contract.yml +++ b/.github/workflows/zcode-account-contract.yml @@ -1,11 +1,5 @@ name: ZCode read-only account contract on: - pull_request: - paths: - - 'src/main/zcode/**' - - 'src/shared/node-bounded-file-reader*' - - 'src/main/rate-limits/**' - - '.github/workflows/zcode-account-contract.yml' workflow_dispatch: permissions: contents: read @@ -13,24 +7,7 @@ concurrency: group: zcode-account-contract-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: - credential-files: - strategy: - fail-fast: false - matrix: - os: [ubuntu-22.04, macos-15, windows-2022] - runs-on: ${{ matrix.os }} - timeout-minutes: 20 - env: - ORCA_BACKGROUND_LAUNCH: '1' - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: node - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: config/.bun-version - - name: Verify read-only credentials and account changes - run: pnpm test src/main/zcode/zcode-v2-plan-credentials.test.ts src/main/zcode/zcode-v2-personal-overlay-raw-json.test.ts src/main/rate-limits/service-zcode-usage.test.ts src/shared/node-bounded-file-reader-sync.test.ts + account-contract: + uses: ./.github/workflows/zcode-credential-contract.yml + with: + contract: account diff --git a/.github/workflows/zcode-credential-contract.yml b/.github/workflows/zcode-credential-contract.yml index 3e991b22a4e..402a552c4af 100644 --- a/.github/workflows/zcode-credential-contract.yml +++ b/.github/workflows/zcode-credential-contract.yml @@ -3,18 +3,29 @@ on: pull_request: paths: - 'src/main/zcode/**' + - 'src/shared/node-bounded-file-reader*' + - 'src/main/rate-limits/**' - 'src/shared/secure-file*' - 'src/shared/secure-path-windows-acl*' - 'src/shared/windows-security-descriptor*' + - '.github/workflows/zcode-account-contract.yml' - '.github/workflows/zcode-credential-contract.yml' + - 'config/scripts/zcode-credential-workflow.test.mjs' workflow_dispatch: + workflow_call: + inputs: + contract: + description: Keep the account workflow's manual entry point on its original test group. + type: string + default: credentials permissions: contents: read concurrency: - group: coding-plan-contract-${{ github.event.pull_request.number || github.ref }} + group: coding-plan-contract-${{ github.event.pull_request.number || github.ref }}-${{ inputs.contract || 'credentials' }} cancel-in-progress: true jobs: credential-files: + name: ZCode credentials (${{ matrix.os }}) strategy: fail-fast: false matrix: @@ -26,12 +37,57 @@ jobs: steps: - uses: actions/checkout@v6 with: + fetch-depth: 2 persist-credentials: false + - name: Select credential test groups + id: scope + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + MANUAL_ACCOUNT: ${{ inputs.contract == 'account' }} + MANUAL_CREDENTIALS: ${{ inputs.contract != 'account' }} + run: | + if [ "$EVENT_NAME" != pull_request ]; then + echo "account=$MANUAL_ACCOUNT" >> "$GITHUB_OUTPUT" + echo "credentials=$MANUAL_CREDENTIALS" >> "$GITHUB_OUTPUT" + else + account=true + credentials=true + diff_status=2 + if git rev-parse --verify HEAD^1 >/dev/null 2>&1; then + diff_status=0 + git diff --quiet --no-renames HEAD^1 HEAD || diff_status=$? + fi + if [ "$diff_status" = 1 ]; then + if git diff --quiet --no-renames HEAD^1 HEAD -- \ + src/main/zcode/ ':(glob)src/shared/node-bounded-file-reader*' src/main/rate-limits/ \ + .github/workflows/zcode-account-contract.yml \ + .github/workflows/zcode-credential-contract.yml \ + config/scripts/zcode-credential-workflow.test.mjs; then + account=false + fi + if git diff --quiet --no-renames HEAD^1 HEAD -- \ + src/main/zcode/ ':(glob)src/shared/secure-file*' ':(glob)src/shared/secure-path-windows-acl*' \ + ':(glob)src/shared/windows-security-descriptor*' \ + .github/workflows/zcode-credential-contract.yml \ + config/scripts/zcode-credential-workflow.test.mjs; then + credentials=false + fi + fi + echo "account=$account" >> "$GITHUB_OUTPUT" + echo "credentials=$credentials" >> "$GITHUB_OUTPUT" + fi - uses: ./.github/actions/install-node-dependencies + id: dependencies with: native-runtime: node - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + id: bun with: bun-version-file: config/.bun-version + - name: Verify read-only credentials and account changes + if: ${{ !cancelled() && steps.scope.outputs.account == 'true' && steps.dependencies.outcome == 'success' && steps.bun.outcome == 'success' }} + run: pnpm test src/main/zcode/zcode-v2-plan-credentials.test.ts src/main/zcode/zcode-v2-personal-overlay-raw-json.test.ts src/main/rate-limits/service-zcode-usage.test.ts src/shared/node-bounded-file-reader-sync.test.ts - name: Verify rollback and native Windows permissions + if: ${{ !cancelled() && steps.scope.outputs.credentials == 'true' && steps.dependencies.outcome == 'success' && steps.bun.outcome == 'success' }} run: pnpm test src/main/zcode/zcode-plan-api-key-store.test.ts src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts src/shared/secure-file.test.ts diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 0aca09ce39e..c02dfa984b3 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { join, resolve } from 'node:path' +import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' @@ -111,11 +112,78 @@ describe('Electron runtime package contract', () => { expect(releaseWorkflowText).not.toContain('blacksmith-') expect(releaseWorkflow.jobs['build-mac']['runs-on']).toBe('ubuntu-latest') expect(releaseWorkflow.jobs['build-mac'].permissions.actions).toBe('write') - expect(macDispatchStep.run).toBe('node config/scripts/run-release-mac-build-workflow.mjs') + expect(macDispatchStep.run).toBe( + 'node config/scripts/run-release-mac-build-workflow.mjs dispatch' + ) expect(macDispatchStep.env.RELEASE_MAC_BUILD_WORKFLOW).toBe('release-mac-build.yml') expect(macDispatchStep.env.RELEASE_MAC_BUILD_TAG).toBe('${{ needs.cut.outputs.tag }}') expect(buildMatrixRunners).not.toContain('blacksmith-6vcpu-macos-15') expect(releaseWorkflow.jobs['publish-release'].needs).toContain('build') expect(releaseWorkflow.jobs['publish-release'].needs).toContain('build-mac') + expect(releaseWorkflow.jobs['publish-release'].needs).toContain('verify-mac-build') + const macVerification = releaseWorkflow.jobs['verify-mac-build'] + expect(macVerification.needs).toEqual(['cut', 'build-mac', 'build']) + expect(macVerification.if).toContain('!cancelled()') + expect(macVerification.if).not.toContain('needs.build.result') + expect(macVerification.permissions).toEqual({ actions: 'read', contents: 'read' }) + expect(releaseWorkflow.jobs['publish-release'].if).toContain( + "needs.verify-mac-build.result == 'success'" + ) + }) + + it('qualifies macOS after failed sibling builds and publishes only complete successful builds', () => { + const { jobs } = parse(readProject('.github/workflows/release-cut.yml')) + const evaluate = (source, needs, cancelled) => + runInNewContext(source.replace(/needs\.([a-z][a-z0-9-]*)/g, 'needs["$1"]'), { + needs, + cancelled: () => cancelled + }) + const results = ['success', 'failure', 'skipped', 'cancelled'] + for (const matrix of results) { + for (const dispatch of results) { + for (const verification of results) { + for (const cancelled of [false, true]) { + const needs = { + cut: { result: 'success', outputs: { should_release: 'true' } }, + build: { result: matrix }, + 'build-mac': { result: dispatch }, + 'verify-mac-build': { result: verification }, + 'skill-sharing-linux-floor-release-gate': { result: 'success' }, + 'skill-sharing-release-gate': { result: 'success' }, + 'terminal-rendering-golden': { result: 'success' } + } + expect(evaluate(jobs['verify-mac-build'].if, needs, cancelled)).toBe( + !cancelled && dispatch === 'success' + ) + expect(evaluate(jobs['publish-release'].if, needs, cancelled)).toBe( + !cancelled && + matrix === 'success' && + dispatch === 'success' && + verification === 'success' + ) + } + } + } + } + for (const prerequisite of [ + 'cut', + 'skill-sharing-linux-floor-release-gate', + 'skill-sharing-release-gate', + 'terminal-rendering-golden' + ]) { + const needs = Object.fromEntries([ + ['cut', { result: 'success', outputs: { should_release: 'true' } }], + ...[ + 'build', + 'build-mac', + 'verify-mac-build', + 'skill-sharing-linux-floor-release-gate', + 'skill-sharing-release-gate', + 'terminal-rendering-golden' + ].map((name) => [name, { result: 'success' }]) + ]) + needs[prerequisite].result = 'failure' + expect(evaluate(jobs['publish-release'].if, needs, false)).toBe(false) + } }) }) diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index e1b3292d117..936d09076a7 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -25,6 +25,7 @@ const EXPECTED_MATRIX = { '.github/workflows/release-mac-build.yml#build-mac': { actions: 'read', contents: 'write' }, [`${RELEASE_WORKFLOW}#build`]: { actions: 'read', contents: 'write' }, [`${RELEASE_WORKFLOW}#build-mac`]: { actions: 'write', contents: 'read' }, + [`${RELEASE_WORKFLOW}#verify-mac-build`]: { actions: 'read', contents: 'read' }, [`${RELEASE_WORKFLOW}#create-release`]: { contents: 'write' }, [`${RELEASE_WORKFLOW}#cut`]: { contents: 'write' }, [`${RELEASE_WORKFLOW}#docs-production-dispatch`]: { actions: 'write' }, diff --git a/config/scripts/release-mac-build-workflow-dispatch.test.mjs b/config/scripts/release-mac-build-workflow-dispatch.test.mjs index 00a8c21a592..edd25b3ab7a 100644 --- a/config/scripts/release-mac-build-workflow-dispatch.test.mjs +++ b/config/scripts/release-mac-build-workflow-dispatch.test.mjs @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { dispatchReleaseMacBuildWorkflow, + executeReleaseMacBuildMode, expectedReleaseMacBuildRunTitle, readReleaseMacBuildWorkflowOptions, runReleaseMacBuildWorkflow @@ -131,6 +132,25 @@ describe('release mac build workflow dispatch', () => { ).rejects.toThrow(/concluded failure/) }) + it('does not accept a combined-mode success first observed after its polling deadline', async () => { + let currentTime = Date.parse('2026-06-30T12:00:00Z') + const { fetch, requests } = createGitHubFetch([ + jsonResponse(200, { workflow_run_id: 123 }), + jsonResponse(200, { id: 123, status: 'in_progress', conclusion: null }), + jsonResponse(200, { id: 123, status: 'completed', conclusion: 'success' }) + ]) + await expect( + runReleaseMacBuildWorkflow(baseOptions, { + fetch, + now: () => currentTime, + sleep: async () => { + currentTime += 3 * 60 * 1000 + } + }) + ).rejects.toThrow(/Timed out after 2m/) + expect(requests.map((request) => request.method)).toEqual(['POST', 'GET']) + }) + it('reads required workflow settings from the GitHub Actions environment', () => { const options = readReleaseMacBuildWorkflowOptions({ GITHUB_REPOSITORY: 'stablyai/orca', @@ -147,6 +167,193 @@ describe('release mac build workflow dispatch', () => { }) }) +const splitEnvironment = { + GITHUB_REPOSITORY: 'stablyai/orca', + GITHUB_RUN_ID: '777', + GITHUB_TOKEN: 'token', + GITHUB_OUTPUT: '/tmp/mac-build-output', + RELEASE_MAC_BUILD_REF: 'main', + RELEASE_MAC_BUILD_TAG: 'v1.2.3-rc.4', + RELEASE_MAC_BUILD_RUN_ID: '123', + RELEASE_MAC_BUILD_WORKFLOW_SHA: 'a'.repeat(40), + RELEASE_MAC_BUILD_RUN_ATTEMPT: '1', + RELEASE_MAC_BUILD_WAIT_DEADLINE_MS: String(Date.parse('2026-06-30T13:30:00Z')) +} +const splitRun = { + id: 123, + run_attempt: 1, + head_sha: 'a'.repeat(40), + head_branch: 'main', + path: '.github/workflows/release-mac-build.yml', + event: 'workflow_dispatch', + display_title: 'Mac release build v1.2.3-rc.4 (777)', + html_url: 'https://github.test/stablyai/orca/actions/runs/123', + updated_at: '2026-06-30T12:10:00Z', + status: 'completed', + conclusion: 'success' +} + +describe('release mac build split dispatch and verification', () => { + it('returns a live run identity without holding a runner while macOS builds', async () => { + const live = { ...splitRun, status: 'in_progress', conclusion: null } + const { fetch, requests } = createGitHubFetch([ + jsonResponse(200, { workflow_run_id: 123, html_url: live.html_url }), + jsonResponse(200, live) + ]) + const appendOutput = vi.fn() + const result = await executeReleaseMacBuildMode('dispatch', splitEnvironment, { + fetch, + appendOutput, + now: () => Date.parse('2026-06-30T12:00:00Z') + }) + expect(result.status).toBe('in_progress') + expect(requests.map((request) => request.method)).toEqual(['POST', 'GET']) + expect(requests[0].headers['X-GitHub-Api-Version']).toBe('2026-03-10') + expect(appendOutput).toHaveBeenCalledWith( + splitEnvironment.GITHUB_OUTPUT, + `run_id=123\nworkflow_sha=${splitRun.head_sha}\nrun_attempt=1\nwait_deadline_ms=${splitEnvironment.RELEASE_MAC_BUILD_WAIT_DEADLINE_MS}\n` + ) + }) + + it.each([null, {}, { workflow_run_id: 0 }, { workflow_run_id: -1 }, { workflow_run_id: 1.5 }])( + 'fails closed when a dispatch response cannot identify its exact child: %j', + async (response) => { + const { fetch, requests } = createGitHubFetch([ + jsonResponse(response == null ? 204 : 200, response), + jsonResponse(200, { workflow_runs: [splitRun] }) + ]) + const appendOutput = vi.fn() + await expect( + executeReleaseMacBuildMode('dispatch', splitEnvironment, { + fetch, + appendOutput, + now: () => Date.parse('2026-06-30T12:00:00Z') + }) + ).rejects.toThrow(/refusing ambiguous discovery/) + expect(requests.map((request) => request.method)).toEqual(['POST']) + expect(appendOutput).not.toHaveBeenCalled() + } + ) + + it('verifies the recorded run without issuing another dispatch', async () => { + const { fetch, requests } = createGitHubFetch([jsonResponse(200, splitRun)]) + const result = await executeReleaseMacBuildMode('wait', splitEnvironment, { + fetch, + now: () => Date.parse('2026-06-30T12:11:00Z') + }) + expect(result.id).toBe(123) + expect(requests.map((request) => request.method)).toEqual(['GET']) + }) + + it.each(['failure', 'cancelled', 'timed_out', 'action_required', null])( + 'blocks publication for the terminal conclusion %s', + async (conclusion) => { + const { fetch } = createGitHubFetch([jsonResponse(200, { ...splitRun, conclusion })]) + await expect( + executeReleaseMacBuildMode('wait', splitEnvironment, { + fetch, + now: () => Date.parse('2026-06-30T12:11:00Z') + }) + ).rejects.toThrow(/concluded/) + } + ) + + it.each([ + ['id', 124], + ['head_sha', 'b'.repeat(40)], + ['head_branch', 'other'], + ['run_attempt', 2], + ['event', 'pull_request'], + ['path', '.github/workflows/other.yml'], + ['display_title', 'Mac release build v1.2.4 (777)'] + ])('rejects a changed %s before inspecting success', async (field, value) => { + const { fetch } = createGitHubFetch([jsonResponse(200, { ...splitRun, [field]: value })]) + await expect( + executeReleaseMacBuildMode('wait', splitEnvironment, { + fetch, + now: () => Date.parse('2026-06-30T12:11:00Z') + }) + ).rejects.toThrow(/does not match/) + }) + + it('accepts a run completed within its original deadline after a slow Windows build', async () => { + const { fetch } = createGitHubFetch([jsonResponse(200, splitRun)]) + await expect( + executeReleaseMacBuildMode('wait', splitEnvironment, { + fetch, + now: () => Date.parse('2026-06-30T17:00:00Z') + }) + ).resolves.toMatchObject({ conclusion: 'success' }) + }) + + it('rejects a successful run that exceeded its original deadline', async () => { + const { fetch } = createGitHubFetch([ + jsonResponse(200, { ...splitRun, updated_at: '2026-06-30T13:31:00Z' }) + ]) + await expect( + executeReleaseMacBuildMode('wait', splitEnvironment, { + fetch, + now: () => Date.parse('2026-06-30T17:00:00Z') + }) + ).rejects.toThrow(/exceeded its original deadline/) + }) + + it('permits only the first late wait-only observation', async () => { + let currentTime = Date.parse('2026-06-30T13:29:59Z') + const { fetch, requests } = createGitHubFetch([ + jsonResponse(200, { ...splitRun, status: 'in_progress', conclusion: null }), + jsonResponse(200, splitRun) + ]) + await expect( + executeReleaseMacBuildMode('wait', splitEnvironment, { + fetch, + now: () => currentTime, + sleep: async () => { + currentTime += 2_000 + } + }) + ).rejects.toThrow(/Timed out/) + expect(requests).toHaveLength(1) + }) + + it('does not reset the polling deadline after the Windows build finishes', async () => { + const { fetch, requests } = createGitHubFetch([ + jsonResponse(200, { ...splitRun, status: 'in_progress', conclusion: null }) + ]) + const sleep = vi.fn() + await expect( + executeReleaseMacBuildMode('wait', splitEnvironment, { + fetch, + sleep, + now: () => Date.parse('2026-06-30T17:00:00Z') + }) + ).rejects.toThrow(/Timed out/) + expect(requests).toHaveLength(1) + expect(sleep).not.toHaveBeenCalled() + }) + + it.each([ + ['RELEASE_MAC_BUILD_RUN_ID', ''], + ['RELEASE_MAC_BUILD_RUN_ID', '../456'], + ['RELEASE_MAC_BUILD_WORKFLOW_SHA', ''], + ['RELEASE_MAC_BUILD_RUN_ATTEMPT', ''], + ['RELEASE_MAC_BUILD_WAIT_DEADLINE_MS', ''] + ])('fails closed without valid %s', async (field, value) => { + const fetch = vi.fn() + await expect( + executeReleaseMacBuildMode( + 'wait', + { + ...splitEnvironment, + [field]: value + }, + { fetch } + ) + ).rejects.toThrow() + expect(fetch).not.toHaveBeenCalled() + }) +}) + function createGitHubFetch(responses) { const requests = [] const fetch = vi.fn(async (rawUrl, init) => { @@ -160,6 +367,7 @@ function createGitHubFetch(responses) { requests.push({ body: init.body == null ? undefined : JSON.parse(init.body), method: init.method, + headers: init.headers, path: url.pathname, query: url.searchParams }) diff --git a/config/scripts/run-release-mac-build-workflow.mjs b/config/scripts/run-release-mac-build-workflow.mjs index 058647b1d93..3e7f3a7450a 100644 --- a/config/scripts/run-release-mac-build-workflow.mjs +++ b/config/scripts/run-release-mac-build-workflow.mjs @@ -1,3 +1,5 @@ +import { appendFileSync } from 'node:fs' + const DEFAULT_API_VERSION = '2026-03-10' const DEFAULT_TIMEOUT_MINUTES = 90 const DEFAULT_POLL_SECONDS = 30 @@ -29,27 +31,56 @@ export function expectedReleaseMacBuildRunTitle({ releaseRunId, tag }) { } export async function runReleaseMacBuildWorkflow(options, deps = {}) { + const workflowRun = await startReleaseMacBuildWorkflow(options, deps) + return await finishReleaseMacBuildWorkflow(options, workflowRun.id, deps) +} + +export async function startReleaseMacBuildWorkflow(options, deps = {}) { const api = createGitHubApiClient(options, deps) const now = deps.now ?? Date.now const sleep = deps.sleep ?? sleepMilliseconds const dispatchStartedAtMs = now() - 10_000 const dispatchResult = await dispatchReleaseMacBuildWorkflow(api, options) + const directRun = readWorkflowRunFromDispatchResult(dispatchResult) + // The pinned API returns its run ID; a title match cannot identify the POST. + if (deps.requireDirectRunId && !directRun) { + throw new Error( + 'GitHub dispatch did not return its workflow run ID; refusing ambiguous discovery.' + ) + } const workflowRun = - readWorkflowRunFromDispatchResult(dispatchResult) ?? + directRun ?? (await findDispatchedReleaseMacBuildRun(api, options, { dispatchStartedAtMs, now, sleep })) - console.log( - `Waiting for mac release build workflow run ${workflowRun.id}: ${workflowRun.html_url}` - ) + console.log(`Dispatched mac release build workflow ${workflowRun.id}: ${workflowRun.html_url}`) + return workflowRun +} - const completedRun = await waitForReleaseMacBuildRun(api, workflowRun.id, options, { - now, - sleep - }) +export function assertReleaseMacBuildRunIdentity(run, options, expected) { + if ( + String(run.id) !== String(expected.id) || + run.event !== 'workflow_dispatch' || + run.path !== `.github/workflows/${options.workflow}` || + run.display_title !== expectedReleaseMacBuildRunTitle(options) || + run.head_branch !== options.ref || + !/^[a-f0-9]{40}$/.test(run.head_sha ?? '') || + !Number.isSafeInteger(run.run_attempt) || + run.run_attempt <= 0 || + (expected.headSha && run.head_sha !== expected.headSha) || + (expected.attempt && run.run_attempt !== expected.attempt) + ) { + throw new Error(`Mac release build run ${expected.id} does not match the dispatched workflow.`) + } + return run +} + +export async function finishReleaseMacBuildWorkflow(options, workflowRunId, deps = {}) { + const api = createGitHubApiClient(options, deps) + const completedRun = await waitForReleaseMacBuildRun(api, workflowRunId, options, deps) if (completedRun.conclusion !== 'success') { throw new Error( @@ -117,17 +148,33 @@ export async function findDispatchedReleaseMacBuildRun(api, options, deps = {}) export async function waitForReleaseMacBuildRun(api, workflowRunId, options, deps = {}) { const now = deps.now ?? Date.now const sleep = deps.sleep ?? sleepMilliseconds - const deadlineMs = now() + options.timeoutMinutes * 60 * 1000 + const deadlineMs = deps.deadlineMs ?? now() + options.timeoutMinutes * 60 * 1000 - while (now() <= deadlineMs) { + let firstObservation = true + while (now() <= deadlineMs || (firstObservation && deps.deadlineMs != null)) { + firstObservation = false const run = await api.request( 'GET', `/repos/${api.owner}/${api.repo}/actions/runs/${workflowRunId}` ) + if (deps.expectedIdentity) { + assertReleaseMacBuildRunIdentity(run, options, deps.expectedIdentity) + } if (run.status === 'completed') { + if (deps.deadlineMs != null) { + const completedAtMs = Date.parse(run.updated_at ?? '') + if (!Number.isFinite(completedAtMs) || completedAtMs > deadlineMs) { + throw new Error( + `Mac release build workflow ${workflowRunId} exceeded its original deadline.` + ) + } + } return run } + if (now() > deadlineMs) { + break + } console.log( `Mac release build workflow is ${run.status}; polling again in ${options.pollSeconds}s` @@ -180,7 +227,7 @@ export function createGitHubApiClient(options, deps = {}) { } function readWorkflowRunFromDispatchResult(result) { - if (Number.isInteger(result?.workflow_run_id)) { + if (Number.isSafeInteger(result?.workflow_run_id) && result.workflow_run_id > 0) { return { html_url: result.html_url, id: result.workflow_run_id @@ -225,8 +272,67 @@ function sleepMilliseconds(milliseconds) { }) } +export async function executeReleaseMacBuildMode(mode, env = process.env, deps = {}) { + const options = readReleaseMacBuildWorkflowOptions(env) + if (mode == null) { + return await runReleaseMacBuildWorkflow(options, deps) + } + if (mode === 'dispatch') { + const output = requiredEnv(env.GITHUB_OUTPUT, 'GITHUB_OUTPUT') + const dispatched = await startReleaseMacBuildWorkflow(options, { + ...deps, + requireDirectRunId: true + }) + const api = createGitHubApiClient(options, deps) + const run = assertReleaseMacBuildRunIdentity( + await api.request('GET', `/repos/${api.owner}/${api.repo}/actions/runs/${dispatched.id}`), + options, + { id: dispatched.id } + ) + const now = deps.now ?? Date.now + const deadlineMs = now() + options.timeoutMinutes * 60 * 1000 + const appendOutput = deps.appendOutput ?? appendFileSync + appendOutput( + output, + `run_id=${run.id}\nworkflow_sha=${run.head_sha}\nrun_attempt=${run.run_attempt}\nwait_deadline_ms=${deadlineMs}\n` + ) + return run + } + if (mode === 'wait') { + const runId = requiredEnv(env.RELEASE_MAC_BUILD_RUN_ID, 'RELEASE_MAC_BUILD_RUN_ID') + if (!/^[1-9][0-9]*$/.test(runId)) { + throw new Error('RELEASE_MAC_BUILD_RUN_ID must be a positive integer.') + } + const headSha = requiredEnv( + env.RELEASE_MAC_BUILD_WORKFLOW_SHA, + 'RELEASE_MAC_BUILD_WORKFLOW_SHA' + ) + if (!/^[a-f0-9]{40}$/.test(headSha)) { + throw new Error('RELEASE_MAC_BUILD_WORKFLOW_SHA must be a commit SHA.') + } + const attempt = Number( + requiredEnv(env.RELEASE_MAC_BUILD_RUN_ATTEMPT, 'RELEASE_MAC_BUILD_RUN_ATTEMPT') + ) + if (!Number.isSafeInteger(attempt) || attempt <= 0) { + throw new Error('RELEASE_MAC_BUILD_RUN_ATTEMPT must be a positive integer.') + } + const deadlineMs = Number( + requiredEnv(env.RELEASE_MAC_BUILD_WAIT_DEADLINE_MS, 'RELEASE_MAC_BUILD_WAIT_DEADLINE_MS') + ) + if (!Number.isSafeInteger(deadlineMs) || deadlineMs <= 0) { + throw new Error('RELEASE_MAC_BUILD_WAIT_DEADLINE_MS must be a positive timestamp.') + } + return await finishReleaseMacBuildWorkflow(options, runId, { + ...deps, + deadlineMs, + expectedIdentity: { id: runId, headSha, attempt } + }) + } + throw new Error(`Unknown mac release build mode: ${mode}`) +} + if (import.meta.url === `file://${process.argv[1]}`) { - runReleaseMacBuildWorkflow(readReleaseMacBuildWorkflowOptions()).catch((error) => { + executeReleaseMacBuildMode(process.argv[2]).catch((error) => { console.error(error) process.exitCode = 1 }) diff --git a/config/scripts/zcode-credential-workflow.test.mjs b/config/scripts/zcode-credential-workflow.test.mjs new file mode 100644 index 00000000000..ff580dcdf41 --- /dev/null +++ b/config/scripts/zcode-credential-workflow.test.mjs @@ -0,0 +1,244 @@ +import { execFileSync } from 'node:child_process' +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, dirname, join } from 'node:path' +import { runInNewContext } from 'node:vm' +import { expect, it } from 'vitest' +import { parse } from 'yaml' + +const workflow = parse(readFileSync('.github/workflows/zcode-credential-contract.yml', 'utf8')) +const accountWorkflow = parse(readFileSync('.github/workflows/zcode-account-contract.yml', 'utf8')) +const job = workflow.jobs['credential-files'] +const scope = job.steps.find((step) => step.id === 'scope') +const account = job.steps.find( + (step) => step.name === 'Verify read-only credentials and account changes' +) +const credentials = job.steps.find( + (step) => step.name === 'Verify rollback and native Windows permissions' +) +const accountFiles = [ + 'src/main/zcode/zcode-v2-plan-credentials.test.ts', + 'src/main/zcode/zcode-v2-personal-overlay-raw-json.test.ts', + 'src/main/rate-limits/service-zcode-usage.test.ts', + 'src/shared/node-bounded-file-reader-sync.test.ts' +] +const credentialFiles = [ + 'src/main/zcode/zcode-plan-api-key-store.test.ts', + 'src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts', + 'src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts', + 'src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts', + 'src/shared/secure-file.test.ts' +] + +it('shares one bootstrap across the original modules and operating systems', () => { + expect(job.strategy).toEqual({ + 'fail-fast': false, + matrix: { os: ['ubuntu-22.04', 'macos-15', 'windows-2022'] } + }) + expect(job.name).toBe('ZCode credentials (${{ matrix.os }})') + expect(account.run.split(/\s+/).slice(2)).toEqual(accountFiles) + expect(credentials.run.split(/\s+/).slice(2)).toEqual(credentialFiles) + expect( + job.steps.filter((step) => step.uses === './.github/actions/install-node-dependencies') + ).toHaveLength(1) + expect(job.steps.find((step) => step.id === 'dependencies').with['native-runtime']).toBe('node') + expect(job.env.ORCA_BACKGROUND_LAUNCH).toBe('1') + expect(job['timeout-minutes']).toBe(20) + expect(job.steps[0].with['fetch-depth']).toBe(2) + expect(workflow.on.pull_request.paths).toEqual([ + 'src/main/zcode/**', + 'src/shared/node-bounded-file-reader*', + 'src/main/rate-limits/**', + 'src/shared/secure-file*', + 'src/shared/secure-path-windows-acl*', + 'src/shared/windows-security-descriptor*', + '.github/workflows/zcode-account-contract.yml', + '.github/workflows/zcode-credential-contract.yml', + 'config/scripts/zcode-credential-workflow.test.mjs' + ]) + expect(accountWorkflow.on).toEqual({ workflow_dispatch: null }) + expect(accountWorkflow.jobs['account-contract']).toEqual({ + uses: './.github/workflows/zcode-credential-contract.yml', + with: { contract: 'account' } + }) + expect(workflow.on.workflow_dispatch).toBeNull() + expect(workflow.on.workflow_call.inputs.contract.default).toBe('credentials') +}) + +it('runs the other selected contract after a sibling fails and retains prerequisite failures', () => { + for (const step of [account, credentials]) { + expect(step['continue-on-error']).toBeUndefined() + for (const [cancelled, installed, bun, selected, expected] of [ + [false, 'success', 'success', 'true', true], + [false, 'failure', 'success', 'true', false], + [false, 'success', 'failure', 'true', false], + [false, 'success', 'success', 'false', false], + [true, 'success', 'success', 'true', false] + ]) { + expect( + runInNewContext(step.if.slice(3, -2), { + cancelled: () => cancelled, + steps: { + scope: { outputs: { account: selected, credentials: selected } }, + dependencies: { outcome: installed }, + bun: { outcome: bun } + } + }) + ).toBe(expected) + } + } +}) + +function executeScope(directory, overrides = {}) { + const output = join(directory, 'github-output') + writeFileSync(output, '') + execFileSync('bash', ['-e', '-o', 'pipefail', '-c', scope.run], { + cwd: directory, + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + EVENT_NAME: 'pull_request', + MANUAL_ACCOUNT: 'false', + MANUAL_CREDENTIALS: 'true', + GITHUB_OUTPUT: output, + ...overrides + } + }) + return Object.fromEntries( + readFileSync(output, 'utf8') + .trim() + .split('\n') + .map((line) => line.split('=')) + ) +} + +// The workflow owns Bash execution on Windows; this contract executes the same source on Unix. +it.skipIf(process.platform === 'win32')( + 'selects the original path groups, including deletions and renames', + () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-workflow-')) + const git = (args) => execFileSync('git', args, { cwd: directory, stdio: 'pipe' }) + const write = (file, value) => { + mkdirSync(dirname(join(directory, file)), { recursive: true }) + writeFileSync(join(directory, file), value) + } + const commit = () => { + git(['add', '-A']) + git([ + '-c', + 'user.name=Fixture', + '-c', + 'user.email=fixture@example.test', + 'commit', + '--allow-empty', + '-qm', + 'fixture' + ]) + } + const cases = [ + ['src/main/zcode/credentials.ts', true, true], + ['src/main/zcode/nested/credentials.ts', true, true], + ['src/shared/node-bounded-file-reader-sync.test.ts', true, false], + ['src/main/rate-limits/service-zcode-usage.ts', true, false], + ['src/shared/secure-file.ts', false, true], + ['src/shared/secure-path-windows-acl.ts', false, true], + ['src/shared/windows-security-descriptor.ts', false, true], + ['.github/workflows/zcode-account-contract.yml', true, false], + ['.github/workflows/zcode-credential-contract.yml', true, true], + ['config/scripts/zcode-credential-workflow.test.mjs', true, true], + ['docs/unrelated.md', false, false] + ] + try { + git(['init', '-q']) + write('README.md', 'fixture') + commit() + expect(executeScope(directory)).toEqual({ account: 'true', credentials: 'true' }) + for (const [file, runsAccount, runsCredentials] of cases) { + write(file, 'first') + commit() + expect(executeScope(directory), file).toEqual({ + account: String(runsAccount), + credentials: String(runsCredentials) + }) + rmSync(join(directory, file)) + commit() + expect(executeScope(directory), `deleted ${file}`).toEqual({ + account: String(runsAccount), + credentials: String(runsCredentials) + }) + } + write('src/main/zcode/credentials.ts', 'renamed') + commit() + renameSync(join(directory, 'src/main/zcode/credentials.ts'), join(directory, 'README.md')) + commit() + expect(executeScope(directory)).toEqual({ account: 'true', credentials: 'true' }) + write('src/main/rate-limits/service.ts', 'mixed') + write('src/shared/secure-file.ts', 'mixed') + commit() + expect(executeScope(directory)).toEqual({ account: 'true', credentials: 'true' }) + commit() + expect(executeScope(directory)).toEqual({ account: 'true', credentials: 'true' }) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + } +) + +it.skipIf(process.platform === 'win32')( + 'keeps both manual entry points and missing diff evidence', + () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-manual-')) + try { + expect(executeScope(directory)).toEqual({ account: 'true', credentials: 'true' }) + expect(executeScope(directory, { EVENT_NAME: 'workflow_dispatch' })).toEqual({ + account: 'false', + credentials: 'true' + }) + expect( + executeScope(directory, { + EVENT_NAME: 'workflow_dispatch', + MANUAL_ACCOUNT: 'true', + MANUAL_CREDENTIALS: 'false' + }) + ).toEqual({ account: 'true', credentials: 'false' }) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + } +) + +it.skipIf(process.platform === 'win32')( + 'runs both groups when the full diff fails even if scoped diffs would report no changes', + () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-diff-failure-')) + try { + const gitShim = join(directory, 'git') + writeFileSync( + gitShim, + '#!/bin/sh\nif [ "$1" = rev-parse ]; then exit 0; fi\nif [ "$#" = 5 ]; then exit "$FULL_DIFF_STATUS"; fi\nexit 0\n' + ) + chmodSync(gitShim, 0o755) + const env = { PATH: `${directory}${delimiter}${process.env.PATH}` } + expect(executeScope(directory, { ...env, FULL_DIFF_STATUS: '1' })).toEqual({ + account: 'false', + credentials: 'false' + }) + for (const status of ['2', '128']) { + expect(executeScope(directory, { ...env, FULL_DIFF_STATUS: status })).toEqual({ + account: 'true', + credentials: 'true' + }) + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } + } +) diff --git a/tests/e2e/helpers/orcad-convert-teardown.ts b/tests/e2e/helpers/orcad-convert-teardown.ts new file mode 100644 index 00000000000..1c26c4507fb --- /dev/null +++ b/tests/e2e/helpers/orcad-convert-teardown.ts @@ -0,0 +1,57 @@ +import type { OrcadManagedStopResult } from '../../../src/shared/orcad-managed-runtime' + +export type ConvertedHostTeardown = { + environmentId: string + targetId: string + worktreeId: string +} + +/** Runs in the renderer; only this fixture's converted SSH environment can be stopped. */ +export async function decommissionConvertedHost({ + environmentId, + targetId, + worktreeId +}: ConvertedHostTeardown): Promise { + const managedOrcad = window.api.runtimeEnvironments.managedOrcad + if (!managedOrcad) { + throw new Error('Converted host teardown requires the desktop managed server API') + } + const environments = await window.api.runtimeEnvironments.list() + const owned = environments.filter( + (environment) => + environment.id === environmentId && + environment.orcadDeployment?.kind !== 'wsl' && + environment.orcadDeployment?.sshTargetId === targetId + ) + if (owned.length !== 1) { + throw new Error('Converted host teardown could not verify its environment and SSH target') + } + const closed = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'terminal.closeAll', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 120_000 + }) + const receipt = closed.ok ? closed.result : null + if ( + !receipt || + typeof receipt !== 'object' || + !('retiredSurfaces' in receipt) || + receipt.retiredSurfaces !== true || + ('ptyStopVerdict' in receipt && receipt.ptyStopVerdict !== undefined) + ) { + throw new Error(`Converted host terminal stop was not proven: ${JSON.stringify(closed)}`) + } + const stopped = await managedOrcad.stop({ + selector: environmentId + }) + if ( + stopped.outcome !== 'unlinked' || + stopped.verdict !== 'exited' || + stopped.environmentId !== environmentId || + stopped.sshTargetId !== targetId + ) { + throw new Error(`Converted host decommission was not proven: ${JSON.stringify(stopped)}`) + } + return stopped +} diff --git a/tests/e2e/helpers/orcad-convert-teardown.unit.test.ts b/tests/e2e/helpers/orcad-convert-teardown.unit.test.ts new file mode 100644 index 00000000000..816a10bdbcb --- /dev/null +++ b/tests/e2e/helpers/orcad-convert-teardown.unit.test.ts @@ -0,0 +1,131 @@ +import { runInNewContext } from 'node:vm' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { decommissionConvertedHost } from './orcad-convert-teardown' + +const owned = { environmentId: 'managed-test', targetId: 'test-ssh', worktreeId: 'test-worktree' } + +function fixture() { + const order: string[] = [] + const list = vi.fn< + () => Promise<{ id: string; orcadDeployment: { sshTargetId: string; kind?: string } }[]> + >(async () => [ + { id: 'unrelated', orcadDeployment: { sshTargetId: 'another-target' } }, + { id: owned.environmentId, orcadDeployment: { sshTargetId: owned.targetId } } + ]) + const close = vi.fn<(args: unknown) => Promise>(async () => { + order.push('close') + return { ok: true, result: { closed: 1, stopped: 1, retiredSurfaces: true } } + }) + const stop = vi.fn<(args: unknown) => Promise>(async () => { + order.push('stop') + return { + outcome: 'unlinked', + verdict: 'exited', + environmentId: owned.environmentId, + sshTargetId: owned.targetId + } + }) + const rendererWindow = { + api: { runtimeEnvironments: { list, call: close, managedOrcad: { stop } } } + } + vi.stubGlobal('window', rendererWindow) + return { list, close, stop, order, rendererWindow } +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('converted host fixture teardown', () => { + it('closes the owned remote workspace before requesting instance-bound decommission', async () => { + const { close, stop, order } = fixture() + await expect(decommissionConvertedHost(owned)).resolves.toMatchObject({ + outcome: 'unlinked', + verdict: 'exited' + }) + expect(close).toHaveBeenCalledWith({ + selector: owned.environmentId, + method: 'terminal.closeAll', + params: { worktree: `id:${owned.worktreeId}` }, + timeoutMs: 120_000 + }) + expect(stop).toHaveBeenCalledWith({ selector: owned.environmentId }) + expect(order).toEqual(['close', 'stop']) + }) + + it('runs the serialized renderer callback without test-process closures', async () => { + const { rendererWindow } = fixture() + const callback: unknown = runInNewContext(`(${decommissionConvertedHost.toString()})`, { + window: rendererWindow + }) + if (typeof callback !== 'function') { + throw new Error('Serialized renderer callback was not a function') + } + await expect(callback(owned)).resolves.toMatchObject({ outcome: 'unlinked', verdict: 'exited' }) + }) + + it.each(['missing', 'different-target', 'wsl', 'duplicate'])( + 'refuses %s ownership', + async (kind) => { + const { list, close, stop } = fixture() + const correct = { id: owned.environmentId, orcadDeployment: { sshTargetId: owned.targetId } } + list.mockResolvedValue( + kind === 'missing' + ? [] + : kind === 'different-target' + ? [{ ...correct, orcadDeployment: { sshTargetId: 'another-target' } }] + : kind === 'wsl' + ? [{ ...correct, orcadDeployment: { ...correct.orcadDeployment, kind: 'wsl' } }] + : [correct, correct] + ) + await expect(decommissionConvertedHost(owned)).rejects.toThrow('verify its environment') + expect(close).not.toHaveBeenCalled() + expect(stop).not.toHaveBeenCalled() + } + ) + + it.each([ + { ok: false, error: { code: 'disconnected' } }, + { ok: true, result: null }, + { ok: true, result: { retiredSurfaces: false } }, + { ok: true, result: { retiredSurfaces: true, ptyStopVerdict: 'live' } }, + { ok: true, result: { retiredSurfaces: true, ptyStopVerdict: 'unverifiable' } } + ])('keeps the server when workspace stop is not proven: %j', async (response) => { + const { close, stop } = fixture() + close.mockResolvedValue(response) + await expect(decommissionConvertedHost(owned)).rejects.toThrow('terminal stop was not proven') + expect(stop).not.toHaveBeenCalled() + }) + + it.each(['live', 'unverifiable'])('never calls %s decommission a success', async (verdict) => { + const { stop } = fixture() + stop.mockResolvedValue({ outcome: 'refused', verdict, code: 'not-stopped' }) + await expect(decommissionConvertedHost(owned)).rejects.toThrow(`"verdict":"${verdict}"`) + }) + + it.each(['environmentId', 'sshTargetId'])( + 'refuses a stop receipt for another %s', + async (field) => { + const { stop } = fixture() + stop.mockResolvedValue({ + outcome: 'unlinked', + verdict: 'exited', + environmentId: owned.environmentId, + sshTargetId: owned.targetId, + [field]: 'unrelated' + }) + await expect(decommissionConvertedHost(owned)).rejects.toThrow('decommission was not proven') + } + ) + + it('refuses before closing any terminal when the desktop API is missing', async () => { + const { list, close } = fixture() + vi.stubGlobal('window', { api: { runtimeEnvironments: { list, call: close } } }) + await expect(decommissionConvertedHost(owned)).rejects.toThrow('desktop managed server API') + expect(close).not.toHaveBeenCalled() + }) + + it('preserves loss of contact as a failed observation', async () => { + const { stop } = fixture() + stop.mockRejectedValue(new Error('host unreachable')) + await expect(decommissionConvertedHost(owned)).rejects.toThrow('host unreachable') + }) +}) diff --git a/tests/e2e/ssh-orcad-auto-convert.spec.ts b/tests/e2e/ssh-orcad-auto-convert.spec.ts index 495e8e62bd3..0da77590c16 100644 --- a/tests/e2e/ssh-orcad-auto-convert.spec.ts +++ b/tests/e2e/ssh-orcad-auto-convert.spec.ts @@ -30,6 +30,10 @@ import { } from './helpers/orcad-template-variant' import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' import { toSshExecutionHostId } from '../../src/shared/execution-host' +import { + decommissionConvertedHost, + type ConvertedHostTeardown +} from './helpers/orcad-convert-teardown' import { ORCAD_CONVERT_HOST_ENV, startOrcadConvertHost, @@ -86,6 +90,7 @@ test('a relay-era profile converts its SSH host on the first connect after upgra ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE_SOURCE! }) let app: ElectronApplication | null = null + let teardown: (ConvertedHostTeardown & { page: Page }) | null = null try { // The first launch only establishes the profile the relay-era rows are written into. const first = await session.launch() @@ -137,6 +142,12 @@ test('a relay-era profile converts its SSH host on the first connect after upgra if (!environment) { throw new Error('Converted host is missing from the environment catalog') } + teardown = { + page: upgraded.page, + environmentId: environment.id, + targetId: seeded.targetId, + worktreeId: seeded.worktreeId + } const hostId = `runtime:${environment.id}` as const await upgraded.page.evaluate((hostId) => { const state = window.__store!.getState() @@ -183,10 +194,25 @@ test('a relay-era profile converts its SSH host on the first connect after upgra expect(status.result.app.desktopWindowStatus).toBe('blocked') await upgraded.page.screenshot({ path: testInfo.outputPath('managed-cli-ready.png') }) } finally { - if (app) { - await session.close(app) + try { + // The Windows account survives client exit; stop its owned runtime before the lane's grace. + if (HOST !== 'docker' && teardown) { + const { page, ...owned } = teardown + const stopped = await page.evaluate(decommissionConvertedHost, owned) + await testInfo.attach('converted-host-teardown', { + body: JSON.stringify(stopped), + contentType: 'application/json' + }) + } + } finally { + try { + if (app) { + await session.close(app) + } + } finally { + await session.dispose() + } } - await session.dispose() } })