ci: reduce runner occupancy in release and contract jobs (#28930)

This commit is contained in:
Neil
2026-10-14 19:53:57 -07:00
committed by GitHub
parent ee5efad8d8
commit d371e2d8b6
11 changed files with 965 additions and 45 deletions
+47 -1
View File
@@ -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'
+4 -27
View File
@@ -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
})
+118 -12
View File
@@ -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')
})
})
+29 -3
View File
@@ -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()
}
})