mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
ci: reduce runner occupancy in release and contract jobs (#28930)
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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<OrcadManagedStopResult> {
|
||||
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
|
||||
}
|
||||
@@ -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<unknown>>(async () => {
|
||||
order.push('close')
|
||||
return { ok: true, result: { closed: 1, stopped: 1, retiredSurfaces: true } }
|
||||
})
|
||||
const stop = vi.fn<(args: unknown) => Promise<unknown>>(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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user