perf(release): overlap Windows builds and gate signing on Mac success (#31032)

This commit is contained in:
Neil
2026-10-20 00:21:25 -07:00
committed by GitHub
parent 509f19867c
commit cf6bbd30da
11 changed files with 895 additions and 85 deletions
+84 -65
View File
@@ -128,7 +128,7 @@ jobs:
echo "Resolved $REQUESTED_REF -> $sha"
echo "sha=$sha" >>"$GITHUB_OUTPUT"
# Why: a branch cut before the orcad template landed has none to build or ship.
# Resolve shared build identity while detecting whether this ref ships a template.
orcad-template-support:
needs: resolve-ref
runs-on: ubuntu-latest
@@ -137,13 +137,17 @@ jobs:
contents: read
outputs:
ships: ${{ steps.detect.outputs.ships }}
version: ${{ steps.adhoc.outputs.version }}
commit: ${{ steps.adhoc.outputs.commit }}
name: ${{ steps.adhoc.outputs.name }}
steps:
- name: Checkout the template packager only
- name: Checkout template detection and version scripts
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve-ref.outputs.sha }}
sparse-checkout: |
/config/scripts/packaged-orcad-template.cjs
/config/scripts/
/package.json
/config/tsconfig.relay.json
sparse-checkout-cone-mode: false
persist-credentials: false
@@ -166,6 +170,47 @@ jobs:
echo "::notice::This ref predates the orcad template; the build ships without it."
fi
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
package-manager-cache: false
- name: Compute adhoc version
id: adhoc
shell: bash
env:
REF: ${{ inputs.ref || github.ref_name }}
LABEL: ${{ inputs.label }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Why this check: the version scripts are read from the branch being built,
# not from main, so a branch cut before the adhoc channel landed has no
# copy of them. Say that plainly instead of failing with a module-not-found.
for script in adhoc-build-version dev-channel-base-version; do
if [[ ! -f "config/scripts/$script.mjs" ]]; then
echo "::error::$REF has no config/scripts/$script.mjs; rebase it onto a main that has the adhoc channel."
exit 1
fi
done
# Why git tags, not GitHub releases: unpublishing a buggy cut deletes the
# GitHub release and leaves the tag, which still owns that number.
# Releases-only let adhoc sit on a number already taken, so the updater
# would not install it. Empty on failure — the script then falls back
# to package.json.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \
"repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \
--jq '.[].ref | sub("^refs/tags/"; "")' || true)"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \
node config/scripts/adhoc-build-version.mjs \
>"$RUNNER_TEMP/adhoc-identity.txt"
if ! grep -q '^name=' "$RUNNER_TEMP/adhoc-identity.txt"; then
echo "::error::adhoc-build-version.mjs emitted no release name; $REF's copy of the script is out of sync with this workflow."
exit 1
fi
cat "$RUNNER_TEMP/adhoc-identity.txt" >>"$GITHUB_OUTPUT"
# Design D2, as release-cut does: every desktop build ships the orcad template (server JS plus
# every target's addons), merged from the node-server lanes that qualify each slot at this ref.
# Without it an adhoc build cannot deploy managed orcad to an SSH host. No secrets, and the mac
@@ -199,8 +244,8 @@ jobs:
environment: adhoc-mac-build
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.adhoc.outputs.version }}
head_sha: ${{ steps.adhoc.outputs.head_sha }}
version: ${{ needs.orcad-template-support.outputs.version }}
head_sha: ${{ needs.resolve-ref.outputs.sha }}
published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}
ships_orcad_template: ${{ needs.orcad-template-support.outputs.ships }}
runs-on: blacksmith-6vcpu-macos-15
@@ -343,42 +388,6 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Compute adhoc version
id: adhoc
shell: bash
env:
REF: ${{ inputs.ref || github.ref_name }}
LABEL: ${{ inputs.label }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Why this check: the version scripts are read from the branch being built,
# not from main, so a branch cut before the adhoc channel landed has no
# copy of them. Say that plainly instead of failing with a module-not-found.
for script in adhoc-build-version dev-channel-base-version; do
if [[ ! -f "config/scripts/$script.mjs" ]]; then
echo "::error::$REF has no config/scripts/$script.mjs; rebase it onto a main that has the adhoc channel."
exit 1
fi
done
echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
# Why git tags, not GitHub releases: unpublishing a buggy cut deletes the
# GitHub release and leaves the tag, which still owns that number.
# Releases-only let adhoc sit on a number already taken, so the updater
# would not install it. Empty on failure — the script then falls back
# to package.json.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \
"repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \
--jq '.[].ref | sub("^refs/tags/"; "")' || true)"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \
node config/scripts/adhoc-build-version.mjs \
>"$RUNNER_TEMP/adhoc-identity.txt"
if ! grep -q '^name=' "$RUNNER_TEMP/adhoc-identity.txt"; then
echo "::error::adhoc-build-version.mjs emitted no release name; $REF's copy of the script is out of sync with this workflow."
exit 1
fi
cat "$RUNNER_TEMP/adhoc-identity.txt" >>"$GITHUB_OUTPUT"
- name: Build app
run: pnpm build:release
env:
@@ -414,9 +423,9 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: v${{ steps.adhoc.outputs.version }}
NAME: ${{ steps.adhoc.outputs.name }}
SHA: ${{ steps.adhoc.outputs.head_sha }}
TAG: v${{ needs.orcad-template-support.outputs.version }}
NAME: ${{ needs.orcad-template-support.outputs.name }}
SHA: ${{ needs.resolve-ref.outputs.sha }}
REF: ${{ inputs.ref || github.ref_name }}
# Via env, not inline `${{ }}`: both land inside a shell string, and an
# expression expanded there is substituted before bash parses the line
@@ -468,8 +477,8 @@ jobs:
# Why: electron-builder's github publisher targets the repo named in the
# config; the token must therefore carry write access to orca-adhoc.
GH_TOKEN: ${{ steps.app_token.outputs.token }}
ORCA_ADHOC_BUILD_VERSION: ${{ steps.adhoc.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.adhoc.outputs.commit }}
ORCA_ADHOC_BUILD_VERSION: ${{ needs.orcad-template-support.outputs.version }}
ORCA_BUILD_COMMIT: ${{ needs.orcad-template-support.outputs.commit }}
# beforePack and afterPack fail the package when the template is absent.
ORCA_REQUIRE_ORCAD_TEMPLATE: ${{ needs.orcad-template-support.outputs.ships == 'true' && '1' || '' }}
CSC_LINK: ${{ secrets.MAC_CERTS }}
@@ -516,7 +525,7 @@ jobs:
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
NAME: ${{ steps.adhoc.outputs.name }}
NAME: ${{ needs.orcad-template-support.outputs.name }}
run: |
set -euo pipefail
# --title again: electron-builder resolves this draft by tag and may
@@ -597,27 +606,37 @@ jobs:
echo "::warning::Could not prune $tag"
done <<<"$stale"
# Why this runs after the mac leg rather than beside it: the tag and version
# are computed inside that job, so until it has run nothing else can name the
# release to upload into. The cost is small enough not to matter — the Windows
# leg measures ~7.5 min (install 2m45, build 35s, NSIS package 3m) against a
# ~9.5 min mac run, which keeps a adhoc run far inside its interval.
#
# Why `./` rather than a pinned `@main`: `uses:` resolves against the ref this
# file itself came from, which for an ordinary dispatch is main. Someone who
# deliberately points the Actions "Use workflow from" picker at a branch
# already gets that branch's copy of this entire file, so this follows the same
# rule instead of inventing a second one.
# Build unsigned Windows artifacts alongside Mac; publication waits below.
build-adhoc-win:
needs: build-adhoc-mac
# Only once the mac release is actually live: there is no release to upload
# into otherwise, and the Windows workflow refuses to create one.
if: needs.build-adhoc-mac.outputs.published == 'true'
needs: [resolve-ref, orcad-template-support, orcad-template]
if: >-
!cancelled() && github.repository == 'stablyai/orca' &&
needs.orcad-template-support.result == 'success' &&
(needs.orcad-template.result == 'success' ||
(needs.orcad-template.result == 'skipped' &&
needs.orcad-template-support.outputs.ships == 'false'))
uses: ./.github/workflows/dev-channel-win-build.yml
secrets: inherit
with:
channel: adhoc
tag: v${{ needs.orcad-template-support.outputs.version }}
ref: ${{ needs.resolve-ref.outputs.sha }}
version: ${{ needs.orcad-template-support.outputs.version }}
orcad_template: ${{ needs.orcad-template-support.outputs.ships == 'true' }}
stage_only: true
# Upload only after Mac succeeds; an unsigned build may overlap its notarization.
publish-adhoc-win:
needs: [build-adhoc-mac, build-adhoc-win]
if: >-
!cancelled() && needs.build-adhoc-mac.result == 'success' &&
needs.build-adhoc-win.result == 'success' &&
needs.build-adhoc-mac.outputs.published == 'true'
permissions:
contents: read
actions: read
uses: ./.github/workflows/dev-channel-win-publish.yml
secrets: inherit
with:
channel: adhoc
tag: ${{ needs.build-adhoc-mac.outputs.tag }}
ref: ${{ needs.build-adhoc-mac.outputs.head_sha }}
version: ${{ needs.build-adhoc-mac.outputs.version }}
orcad_template: ${{ needs.build-adhoc-mac.outputs.ships_orcad_template == 'true' }}
+52 -9
View File
@@ -5,11 +5,8 @@ name: Dev Channel Windows Build
# Windows artifact can be rebuilt for an existing tag without paying for the mac
# leg's packaging and notarization again.
#
# Each mac workflow calls this as a `needs:`-gated job once its release is live,
# passing the tag it created. Both legs land in that one release, so a tag
# carries every platform it managed to build. Measured cost of the Windows leg is
# ~7.5 min (install 2m45, build 35s, NSIS package 3m) against a mac run of ~9.5
# min, so running it after the mac job keeps an hourly well inside its cron.
# Adhoc can stage Windows alongside macOS, then publish only after macOS succeeds.
# Hourly, daily, and manual dispatches keep packaging and publishing together.
#
# Why unsigned: Windows release installers are signed by SignPath *after*
# packaging, and release-cut budgets 1h + 4h for those approval waits. That does
@@ -27,9 +24,8 @@ name: Dev Channel Windows Build
# installer run. Every way out — to another dev build, or back to Stable — works
# through the in-app updater. The picker offers a download for exactly that jump.
#
# Called as a job by each mac workflow once its release is live, and separately
# dispatchable by hand to rebuild a Windows artifact for an existing tag without
# re-running the mac leg's twenty minutes of packaging and notarization:
# Mac workflows call this directly or stage artifacts for a later publish job.
# It is also dispatchable to rebuild Windows for an existing tag:
#
# gh workflow run dev-channel-win-build.yml --ref main \
# -f channel=adhoc -f tag=v1.4.178-adhoc.20260819010203 \
@@ -47,7 +43,7 @@ on:
required: true
type: string
tag:
description: Existing release tag in the channel repo
description: Release tag for staged or published artifacts
required: true
type: string
ref:
@@ -58,6 +54,11 @@ on:
description: Version to package, without the leading v
required: true
type: string
stage_only:
description: Package unsigned artifacts for a later gated publish job
required: false
type: boolean
default: false
orcad_template:
description: Ship the orcad-template artifact the calling run built
required: false
@@ -276,6 +277,7 @@ jobs:
# Why the token is minted here and not at the top: installation tokens live
# one hour and nothing before this point writes anything.
- name: Mint dev channel repo token
if: ${{ !inputs.stage_only }}
id: app_token
uses: actions/create-github-app-token@v2
with:
@@ -289,6 +291,7 @@ jobs:
# while this was building, that would mint a fresh, untitled, Windows-only
# release. Check first and fail instead.
- name: Confirm the target release still exists
if: ${{ !inputs.stage_only }}
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
@@ -300,6 +303,7 @@ jobs:
fi
- name: Publish Windows artifacts
if: ${{ !inputs.stage_only }}
uses: nick-fields/retry@v4
with:
# 30 is the pack + upload budget; there is no notary queue on this leg.
@@ -323,6 +327,7 @@ jobs:
# release carrying the installer but not the manifest is one the picker
# offers and the update 404s on, so assert both.
- name: Verify Windows update manifest published
if: ${{ !inputs.stage_only }}
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
@@ -338,3 +343,41 @@ jobs:
fi
done
echo "Windows artifacts verified on $TAG."
- name: Package Windows artifacts without publishing
if: inputs.stage_only
uses: nick-fields/retry@v4
with:
timeout_minutes: 30
max_attempts: 2
retry_wait_seconds: 30
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never
env:
ORCA_BUILD_COMMIT: ${{ inputs.ref }}
ORCA_REQUIRE_ORCAD_TEMPLATE: ${{ inputs.orcad_template && '1' || '' }}
- name: Validate staged Windows artifacts
if: inputs.stage_only
shell: bash
run: |
set -euo pipefail
for required in orca-windows-setup.exe orca-windows-setup.exe.blockmap latest.yml; do
if [[ ! -s "dist/$required" ]]; then
echo "::error::Missing or empty staged Windows artifact: $required"
exit 1
fi
done
- name: Stage Windows artifacts for gated publishing
if: inputs.stage_only
uses: actions/upload-artifact@v7
with:
name: dev-channel-windows-${{ inputs.tag }}
path: |
dist/orca-windows-setup.exe
dist/orca-windows-setup.exe.blockmap
dist/latest.yml
if-no-files-found: error
retention-days: 3
compression-level: 0
overwrite: true
@@ -0,0 +1,107 @@
name: Dev Channel Windows Publish
on:
workflow_call:
inputs:
channel:
description: Dev channel whose live release receives the staged artifacts
required: true
type: string
tag:
description: Release tag used when staging the Windows artifacts
required: true
type: string
permissions:
contents: read
actions: read
concurrency:
group: dev-channel-win-publish-${{ inputs.tag }}
cancel-in-progress: false
jobs:
publish-win:
if: github.repository == 'stablyai/orca'
environment: adhoc-mac-build
runs-on: ubuntu-latest
timeout-minutes: 15
env:
CHANNEL: ${{ inputs.channel }}
TAG: ${{ inputs.tag }}
steps:
- name: Vet the requested inputs
shell: bash
run: |
set -euo pipefail
case "$CHANNEL" in
hourly|daily|adhoc) ;;
*) echo "::error::Unknown dev channel '$CHANNEL'."; exit 1 ;;
esac
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-${CHANNEL}\.[0-9]+$ ]]; then
echo "::error::Tag '$TAG' is not a $CHANNEL release tag."
exit 1
fi
# No run-id: artifacts must belong to this run, never another dispatch.
- name: Download the staged Windows artifacts
uses: actions/download-artifact@v8
with:
name: dev-channel-windows-${{ inputs.tag }}
path: staged-windows
- name: Validate staged Windows artifacts
shell: bash
run: |
set -euo pipefail
for required in orca-windows-setup.exe orca-windows-setup.exe.blockmap latest.yml; do
if [[ ! -s "staged-windows/$required" ]]; then
echo "::error::Missing or empty staged Windows artifact: $required"
exit 1
fi
done
- name: Mint dev channel repo token
id: app_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
owner: stablyai
repositories: orca-${{ inputs.channel }}
- name: Confirm the target release is live
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
draft="$(gh release view "$TAG" --repo "stablyai/orca-$CHANNEL" --json isDraft --jq '.isDraft')"
if [[ "$draft" != false ]]; then
echo "::error::Release $TAG is not live; refusing to publish Windows artifacts."
exit 1
fi
- name: Publish Windows artifacts
uses: nick-fields/retry@v4
with:
timeout_minutes: 5
max_attempts: 2
retry_wait_seconds: 10
command: gh release upload "$TAG" staged-windows/orca-windows-setup.exe staged-windows/orca-windows-setup.exe.blockmap staged-windows/latest.yml --clobber --repo "stablyai/orca-$CHANNEL"
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
- name: Verify Windows update manifest published
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
assets="$(gh release view "$TAG" --repo "stablyai/orca-$CHANNEL" --json assets --jq '.assets[].name')"
for required in latest.yml orca-windows-setup.exe orca-windows-setup.exe.blockmap; do
if ! grep -qx "$required" <<<"$assets"; then
echo "::error::$TAG is missing $required; Windows could not install this build."
exit 1
fi
done
+22 -3
View File
@@ -1227,6 +1227,7 @@ jobs:
- orcad-template
- release-preflight
- release-javascript
- build-mac
# Why not the implicit success(): a tag without the orcad template skips that job on purpose.
if: >-
!cancelled() &&
@@ -1235,6 +1236,7 @@ jobs:
needs.create-release.result == 'success' &&
needs.release-preflight.result == 'success' &&
needs.release-javascript.result == 'success' &&
needs.build-mac.result == 'success' &&
(needs.orcad-template.result == 'success' ||
(needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false'))
env:
@@ -1309,7 +1311,7 @@ jobs:
run: |
set -euo pipefail
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
git checkout "$WORKFLOW_SHA" -- config/scripts/assert-github-release-is-draft.mjs
git checkout "$WORKFLOW_SHA" -- config/scripts/assert-github-release-is-draft.mjs config/scripts/run-release-mac-build-workflow.mjs
- name: Restore composite actions from the workflow ref
shell: bash
@@ -1689,9 +1691,26 @@ jobs:
path: signing-stage/**
if-no-files-found: error
# Unsigned packaging overlaps macOS; only successful macOS builds may consume signing quota.
- name: Require macOS success before Windows signing
id: mac-signing-gate
if: matrix.platform == 'win' && github.run_attempt == 1
shell: bash
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
- name: Submit inner binaries signing request
id: submit-inner-signing
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.upload-unsigned-inner.outcome == 'success'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.mac-signing-gate.outcome == 'success' && steps.upload-unsigned-inner.outcome == 'success'
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
@@ -1927,7 +1946,7 @@ jobs:
# so the release job waits while the signing request is approved in UI.
- name: Submit Windows installer signing request
id: submit-signing-request
if: matrix.platform == 'win' && github.run_attempt == 1
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.mac-signing-gate.outcome == 'success' && steps.upload-unsigned-windows-installer.outcome == 'success'
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
@@ -0,0 +1,79 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { compileAggregateExpression } from './pr-aggregate-gate-context.mjs'
const { jobs } = parse(readFileSync('.github/workflows/adhoc-mac-build.yml', 'utf8'))
const mac = jobs['build-adhoc-mac']
const windows = jobs['build-adhoc-win']
const publish = jobs['publish-adhoc-win']
function qualification(template = 'success', ships = 'true') {
return {
github: { repository: 'stablyai/orca' },
needs: {
'resolve-ref': { result: 'success', outputs: { sha: 'a'.repeat(40) } },
'orcad-template-support': { result: 'success', outputs: { ships } },
'orcad-template': { result: template }
}
}
}
describe('adhoc Windows compilation overlaps Mac without publishing early', () => {
it('starts both platforms from the same qualified inputs without a Mac dependency', () => {
expect(windows.needs).toEqual(mac.needs)
expect(windows.needs).not.toContain('build-adhoc-mac')
expect(windows.with.stage_only).toBe(true)
expect(windows.with.ref).toBe('${{ needs.resolve-ref.outputs.sha }}')
expect(windows.with.tag).toBe('v${{ needs.orcad-template-support.outputs.version }}')
expect(windows.with.version).toBe(mac.outputs.version)
expect(windows.with.orcad_template).toBe(
"${{ needs.orcad-template-support.outputs.ships == 'true' }}"
)
const identity = jobs['orcad-template-support']
expect(identity.outputs.version).toBe('${{ steps.adhoc.outputs.version }}')
expect(identity.steps.filter((step) => step.id === 'adhoc')).toHaveLength(1)
expect(mac.steps.some((step) => step.id === 'adhoc')).toBe(false)
expect(mac.steps.find((step) => step.name === 'Create adhoc release').env.TAG).toBe(
windows.with.tag
)
})
it.each([
['success', 'true', true],
['failure', 'true', false],
['cancelled', 'true', false],
['skipped', 'true', false],
['skipped', 'false', true]
])('preserves qualification for %s / template=%s', (result, ships, allowed) => {
const run = compileAggregateExpression(windows.if, ['needs', 'github'])
const context = qualification(result, ships)
expect(run(context).coerceString()).toBe(String(allowed))
expect(run(context, true).coerceString()).toBe('false')
context.needs['orcad-template-support'].result = 'failure'
expect(run(context).coerceString()).toBe('false')
})
it.each(['success', 'failure', 'cancelled', 'skipped', ''])(
'requires successful Mac publication and Windows compilation: %s',
(result) => {
const run = compileAggregateExpression(publish.if)
const needs = {
'build-adhoc-mac': { result: 'success', outputs: { published: 'true' } },
'build-adhoc-win': { result: 'success' }
}
expect(publish.needs).toEqual(['build-adhoc-mac', 'build-adhoc-win'])
expect(run({ needs }).coerceString()).toBe('true')
expect(run({ needs }, true).coerceString()).toBe('false')
for (const job of publish.needs) {
expect(run({ needs: { ...needs, [job]: { ...needs[job], result } } }).coerceString()).toBe(
String(result === 'success')
)
}
for (const published of ['false', '', undefined]) {
needs['build-adhoc-mac'].outputs.published = published
expect(run({ needs }).coerceString()).toBe('false')
}
}
)
})
@@ -0,0 +1,137 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { runProcess } from '@orca/process-host'
function readWorkflow(name) {
return parse(readFileSync(new URL(`../../.github/workflows/${name}`, import.meta.url), 'utf8'))
}
const build = readWorkflow('dev-channel-win-build.yml')
const publish = readWorkflow('dev-channel-win-publish.yml')
const buildSteps = build.jobs['build-win'].steps
const publishSteps = publish.jobs['publish-win'].steps
const directory = mkdtempSync(join(tmpdir(), 'dev-channel-windows-staging-'))
afterAll(() => rmSync(directory, { recursive: true, force: true }))
async function runScript(script, { cwd = directory, env = {} } = {}) {
return runProcess({ program: 'bash', args: ['-c', script], cwd, env: { ...process.env, ...env } })
}
describe('Windows dev-channel artifact staging', () => {
it('keeps existing callers publishing by default', () => {
expect(build.on.workflow_call.inputs.stage_only).toMatchObject({
type: 'boolean',
default: false,
required: false
})
expect(build.on.workflow_dispatch.inputs.stage_only).toBeUndefined()
const normalPackage = buildSteps.find((step) => step.name === 'Publish Windows artifacts')
expect(normalPackage.with.command).toContain('--publish always')
expect(normalPackage.if).toBe('${{ !inputs.stage_only }}')
})
it('never mints or consumes release-write credentials while staging', () => {
for (const step of buildSteps) {
const credentialed = JSON.stringify(step).includes('secrets.') || step.env?.GH_TOKEN
if (credentialed) {
expect(step.if, step.name).toBe('${{ !inputs.stage_only }}')
}
}
const stagePackage = buildSteps.find(
(step) => step.name === 'Package Windows artifacts without publishing'
)
expect(stagePackage.if).toBe('inputs.stage_only')
expect(stagePackage.with.command).toContain('--publish never')
expect(stagePackage.env.GH_TOKEN).toBeUndefined()
for (const workflow of [build, publish]) {
const contents = JSON.stringify(workflow.jobs)
expect(contents).not.toMatch(/SignPath|SIGNPATH|signpath/)
}
})
it('passes exactly the installer, blockmap, and update manifest within the same run', () => {
const upload = buildSteps.find((step) => step.uses?.startsWith('actions/upload-artifact@'))
const download = publishSteps.find((step) =>
step.uses?.startsWith('actions/download-artifact@')
)
expect(upload.if).toBe('inputs.stage_only')
expect(upload.with.path.trim().split('\n')).toEqual([
'dist/orca-windows-setup.exe',
'dist/orca-windows-setup.exe.blockmap',
'dist/latest.yml'
])
expect(upload.with['if-no-files-found']).toBe('error')
expect(download.with).toEqual({
name: upload.with.name,
path: 'staged-windows'
})
expect(build.concurrency.group).not.toBe(publish.concurrency.group)
})
it.each([
['build', buildSteps, 'dist'],
['publish', publishSteps, 'staged-windows']
])('fails %s on any missing or empty staged file', async (_name, steps, folder) => {
const step = steps.find((entry) => entry.name === 'Validate staged Windows artifacts')
const attempt = mkdtempSync(join(directory, 'files-'))
mkdirSync(join(attempt, folder))
expect((await runScript(step.run, { cwd: attempt })).code).toBe(1)
for (const file of [
'orca-windows-setup.exe',
'orca-windows-setup.exe.blockmap',
'latest.yml'
]) {
writeFileSync(join(attempt, folder, file), '')
expect((await runScript(step.run, { cwd: attempt })).code).toBe(1)
writeFileSync(join(attempt, folder, file), 'artifact')
}
expect((await runScript(step.run, { cwd: attempt })).code).toBe(0)
})
it('validates the channel/tag before downloading artifacts or minting a token', async () => {
const step = publishSteps[0]
for (const [channel, tag, code] of [
['adhoc', 'v1.4.232-adhoc.20261020052302', 0],
['daily', 'v1.4.232-daily.20261020052302', 0],
['hourly', 'v1.4.232-hourly.20261020052302', 0],
['stable', 'v1.4.232-stable.20261020052302', 1],
['adhoc', 'v1.4.232-daily.20261020052302', 1],
['adhoc', '--help', 1],
['adhoc', '$(exit 99)', 1]
]) {
expect((await runScript(step.run, { env: { CHANNEL: channel, TAG: tag } })).code).toBe(code)
}
})
it('requires a live existing release and never creates one', async () => {
const guard = publishSteps.find((step) => step.name === 'Confirm the target release is live')
for (const [response, code] of [
['false', 0],
['true', 1],
['', 1]
]) {
const stub = 'gh() { printf "%s" "$STUB_RESPONSE"; }; export -f gh;\n'
expect(
(
await runScript(`${stub}${guard.run}`, {
env: { STUB_RESPONSE: response, CHANNEL: 'adhoc', TAG: 'v1.4.232-adhoc.1' }
})
).code
).toBe(code)
}
expect(
(
await runScript(`gh() { return 1; };\n${guard.run}`, {
env: { CHANNEL: 'adhoc', TAG: 'v1.4.232-adhoc.1' }
})
).code
).toBe(1)
const upload = publishSteps.find((step) => step.name === 'Publish Windows artifacts')
expect(upload.with.command).toContain('gh release upload')
expect(JSON.stringify(publish)).not.toContain('gh release create')
expect(publishSteps.indexOf(guard)).toBeLessThan(publishSteps.indexOf(upload))
})
})
+168
View File
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict'
import { readFileSync, writeFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
function timestamp(value, label) {
const result = Date.parse(value)
assert(Number.isFinite(result), `Missing or invalid ${label}`)
return result
}
function startedStep(job) {
const starts = job.steps
.filter((step) => step.status !== 'skipped' && step.conclusion !== 'skipped')
.map((step) => Date.parse(step.startedAt ?? step.started_at))
.filter(Number.isFinite)
assert(starts.length > 0, `No started steps for ${job.name}`)
return Math.min(...starts)
}
function interval(job) {
const start = startedStep(job)
const end = timestamp(job.completedAt ?? job.completed_at, `${job.name} completion`)
assert(end >= start, `Completion precedes first step for ${job.name}`)
return { start, end }
}
function seconds(milliseconds) {
return Math.round(milliseconds) / 1000
}
function phaseSteps(jobs, pattern) {
const matches = jobs.flatMap((job) =>
job.steps
.filter((step) => step.conclusion === 'success' && pattern.test(step.name))
.map((step) => ({
job: job.name,
step: step.name,
seconds: seconds(
timestamp(step.completedAt ?? step.completed_at, `${step.name} completion`) -
timestamp(step.startedAt ?? step.started_at, `${step.name} start`)
)
}))
)
return {
seconds: matches.length ? matches.reduce((sum, step) => sum + step.seconds, 0) : null,
steps: matches
}
}
function overlapSeconds(mac, windows) {
const segments = windows
.map((win) => ({ start: Math.max(mac.start, win.start), end: Math.min(mac.end, win.end) }))
.filter(({ start, end }) => start < end)
.sort((left, right) => left.start - right.start)
const merged = []
for (const segment of segments) {
const previous = merged.at(-1)
if (previous && previous.end >= segment.start) {
previous.end = Math.max(previous.end, segment.end)
} else {
merged.push({ ...segment })
}
}
return seconds(merged.reduce((sum, { start, end }) => sum + end - start, 0))
}
export function summarizeReleaseDesktopTiming(run) {
assert(
run.status === 'completed' && run.conclusion === 'success',
'Require a completed successful run'
)
const jobs = run.jobs.filter((job) => job.conclusion !== 'skipped')
const mac = jobs.find((job) => job.name === 'build-adhoc-mac')
const windows = jobs.filter((job) => /^(?:build|stage|publish)-adhoc-win(?:\b|\/)/.test(job.name))
const template = jobs.find((job) => job.name === 'orcad-template / desktop_template')
assert(mac && windows.length > 0 && template, 'Missing Mac, Windows, or qualified template job')
assert(
[mac, ...windows, template].every((job) => job.conclusion === 'success'),
'Desktop or template job failed'
)
const qualification = jobs.filter((job) => job.name.startsWith('orcad-template / '))
const macInterval = interval(mac)
const windowsIntervals = windows.map(interval)
const desktopStart = Math.min(macInterval.start, ...windowsIntervals.map(({ start }) => start))
const desktopEnd = Math.max(macInterval.end, ...windowsIntervals.map(({ end }) => end))
const created = timestamp(run.createdAt ?? run.created_at, 'run creation')
const templateEnd = interval(template).end
return {
source: { url: run.url ?? run.html_url ?? null, headSha: run.headSha ?? run.head_sha ?? null },
measuredSeconds: {
creationToLastJob: seconds(Math.max(...jobs.map((job) => interval(job).end)) - created),
qualification: seconds(templateEnd - Math.min(...qualification.map(startedStep))),
desktop: seconds(desktopEnd - desktopStart),
macJob: seconds(macInterval.end - macInterval.start),
windowsJobTotal: seconds(
windowsIntervals.reduce((sum, { start, end }) => sum + end - start, 0)
),
desktopJobOverlap: overlapSeconds(macInterval, windowsIntervals),
templateReadyToMacFirstStep: seconds(macInterval.start - templateEnd),
templateReadyToWindowsFirstStep: seconds(
Math.min(...windowsIntervals.map(({ start }) => start)) - templateEnd
)
},
windowsPhases: {
dependencies: phaseSteps(windows, /^Install dependencies$/),
appBuild: phaseSteps(windows, /^Build app$/),
packagingAndUpload: phaseSteps(
windows,
/^(?:Package Windows artifacts(?: without publishing)?|Publish Windows artifacts)$/
),
artifactHandoff: phaseSteps(
windows,
/^(?:Stage Windows artifacts for gated publishing|Download the staged Windows artifacts)$/
)
},
desktopJobs: [mac, ...windows].map((job) => {
const { start, end } = interval(job)
return {
name: job.name,
firstStepAt: new Date(start).toISOString(),
completedAt: new Date(end).toISOString(),
reportedStartToFirstStepSeconds: seconds(
start - timestamp(job.startedAt ?? job.started_at, `${job.name} reported start`)
)
}
})
}
}
export function compareReleaseDesktopTiming(baseline, candidate) {
const before = summarizeReleaseDesktopTiming(baseline)
const after = summarizeReleaseDesktopTiming(candidate)
return {
methodology: [
'Measured timestamps from two successful GitHub Actions runs; no modeled speedup.',
'Job intervals begin at the first started step, not the reported job start.',
'Creation-to-last-job includes scheduling and runner waits; desktop includes teardown and publication.',
'Template-to-first-step gaps include dependency scheduling and runner waits, not queue time alone.',
'Overlap is the union of Windows job intervals intersecting the Mac job, including setup and teardown.',
'Different source commits, caches, and runner load can affect a two-run comparison.'
],
baseline: before,
candidate: after,
measuredSavingsSeconds: Object.fromEntries(
Object.entries(before.measuredSeconds).map(([key, value]) => [
key,
seconds((value - after.measuredSeconds[key]) * 1000)
])
)
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const [baselineFile, candidateFile, outputFile] = process.argv.slice(2)
assert(
baselineFile && candidateFile && process.argv.length <= 5,
'Usage: node config/scripts/release-desktop-timing.mjs baseline.json candidate.json [report.json]'
)
const report = compareReleaseDesktopTiming(
JSON.parse(readFileSync(baselineFile, 'utf8')),
JSON.parse(readFileSync(candidateFile, 'utf8'))
)
const json = `${JSON.stringify(report, null, 2)}\n`
if (outputFile) {
writeFileSync(outputFile, json)
}
process.stdout.write(json)
}
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import {
compareReleaseDesktopTiming,
summarizeReleaseDesktopTiming
} from './release-desktop-timing.mjs'
const at = (seconds) => new Date(Date.UTC(2026, 0, 1) + seconds * 1000).toISOString()
function job(name, start, end, firstStep = start, steps = []) {
return {
name,
conclusion: 'success',
startedAt: at(start),
completedAt: at(end),
steps: [
{
name: 'Set up job',
conclusion: 'success',
startedAt: at(firstStep),
completedAt: at(firstStep + 1)
},
...steps
]
}
}
function run(windows, macStart = 20, macEnd = 100) {
return {
status: 'completed',
conclusion: 'success',
createdAt: at(0),
jobs: [
job('orcad-template-support', 2, 3),
job('orcad-template / desktop_template', 3, 10),
job('build-adhoc-mac', macStart, macEnd),
...windows
]
}
}
describe('release desktop timing evidence', () => {
it('measures the sequential baseline and actual candidate overlap without counting queue timestamps as execution', () => {
const baseline = run([job('build-adhoc-win / build-win', 100, 160, 110)])
const candidate = run([
job('build-adhoc-win / build-win', 10, 75, 25),
job('publish-adhoc-win / publish-win', 100, 110, 101)
])
const report = compareReleaseDesktopTiming(baseline, candidate)
expect(report.baseline.measuredSeconds.desktopJobOverlap).toBe(0)
expect(report.candidate.measuredSeconds.desktopJobOverlap).toBe(50)
expect(report.candidate.measuredSeconds.desktop).toBe(90)
expect(report.measuredSavingsSeconds.desktop).toBe(50)
expect(report.candidate.desktopJobs[1].reportedStartToFirstStepSeconds).toBe(15)
expect(report.candidate.measuredSeconds.templateReadyToWindowsFirstStep).toBe(15)
})
it('unions intersecting Windows intervals and ignores skipped jobs and skipped step timestamps', () => {
const windows = job('build-adhoc-win / build-win', 10, 80, 30)
windows.steps.unshift({ name: 'Skipped', conclusion: 'skipped', startedAt: at(10) })
const input = run([windows, job('publish-adhoc-win / publish-win', 70, 90)])
input.jobs.push({ name: 'build-adhoc-win-old', conclusion: 'skipped', steps: [] })
const report = summarizeReleaseDesktopTiming(input)
expect(report.measuredSeconds.desktopJobOverlap).toBe(60)
expect(report.measuredSeconds.windowsJobTotal).toBe(70)
})
it('retains measured step evidence and leaves missing phases unknown', () => {
const input = run([
job('build-adhoc-win / build-win', 100, 170, 100, [
{ name: 'Build app', conclusion: 'success', startedAt: at(101), completedAt: at(131) },
{
name: 'Publish Windows artifacts',
conclusion: 'success',
startedAt: at(131),
completedAt: at(160)
}
])
])
const report = summarizeReleaseDesktopTiming(input)
expect(report.windowsPhases.appBuild.seconds).toBe(30)
expect(report.windowsPhases.packagingAndUpload.seconds).toBe(29)
expect(report.windowsPhases.dependencies.seconds).toBeNull()
expect(report.windowsPhases.appBuild.steps[0].job).toBe('build-adhoc-win / build-win')
})
it('accounts for packaging and the extra artifact handoff in a split Windows build', () => {
const step = (name, start, end) => ({
name,
conclusion: 'success',
startedAt: at(start),
completedAt: at(end)
})
const report = summarizeReleaseDesktopTiming(
run([
job('stage-adhoc-win / build-win', 20, 80, 20, [
step('Package Windows artifacts without publishing', 30, 60),
step('Stage Windows artifacts for gated publishing', 60, 70)
]),
job('publish-adhoc-win / publish-win', 100, 130, 100, [
step('Download the staged Windows artifacts', 101, 111),
step('Publish Windows artifacts', 111, 125)
])
])
)
expect(report.windowsPhases.packagingAndUpload.seconds).toBe(44)
expect(report.windowsPhases.artifactHandoff.seconds).toBe(20)
})
it('rejects incomplete runs, failed desktop jobs, and missing timing evidence', () => {
const input = run([job('build-adhoc-win / build-win', 100, 160)])
expect(() => summarizeReleaseDesktopTiming({ ...input, status: 'in_progress' })).toThrow(
'completed successful run'
)
input.jobs.at(-1).conclusion = 'failure'
expect(() => summarizeReleaseDesktopTiming(input)).toThrow('Desktop or template job failed')
input.jobs.at(-1).conclusion = 'success'
input.jobs.at(-1).steps = []
expect(() => summarizeReleaseDesktopTiming(input)).toThrow('No started steps')
})
})
@@ -0,0 +1,114 @@
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runInNewContext } from 'node:vm'
import { runProcess } from '@orca/process-host'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const { jobs } = parse(readFileSync('.github/workflows/release-cut.yml', 'utf8'))
const { steps } = jobs.build
const gate = steps.find((step) => step.id === 'mac-signing-gate')
const submissions = steps.filter((step) =>
step.uses?.startsWith('signpath/github-action-submit-signing-request@')
)
function signingAllowed(submission, outcome, attempt = 1, platform = 'win', uploaded = 'success') {
const expression = submission.if.replace(/steps\.([a-z][a-z0-9-]*)/g, 'steps["$1"]')
return runInNewContext(expression, {
matrix: { platform },
github: { run_attempt: attempt },
steps: {
'mac-signing-gate': { outcome },
'upload-unsigned-inner': { outcome: uploaded },
'upload-unsigned-windows-installer': { outcome: uploaded }
}
})
}
describe('Windows release signing waits for macOS success', () => {
it('packages concurrently, then verifies the exact dispatched macOS run before signing', () => {
expect(jobs.build.needs).toContain('build-mac')
expect(jobs.build.if).toContain("needs.build-mac.result == 'success'")
expect(jobs.build.needs).not.toContain('verify-mac-build')
expect(jobs['build-mac'].needs).not.toContain('build')
expect(jobs['verify-mac-build'].needs).toContain('build')
const packaging = steps.findIndex((step) => step.name === 'Build Windows release artifacts')
const gateIndex = steps.indexOf(gate)
expect(packaging).toBeGreaterThanOrEqual(0)
expect(gateIndex).toBeGreaterThan(packaging)
expect(gate.run).toBe('node config/scripts/run-release-mac-build-workflow.mjs wait')
expect(gate.if).toBe("matrix.platform == 'win' && github.run_attempt == 1")
expect(gate['continue-on-error']).toBeUndefined()
expect(gate.env).toEqual(
jobs['verify-mac-build'].steps.find((step) => step.run === gate.run).env
)
expect(gate.env.RELEASE_MAC_BUILD_RUN_ID).toBe('${{ needs.build-mac.outputs.run_id }}')
expect(gate.env.RELEASE_MAC_BUILD_WORKFLOW_SHA).toBe(
'${{ needs.build-mac.outputs.workflow_sha }}'
)
expect(gate.env.RELEASE_MAC_BUILD_RUN_ATTEMPT).toBe(
'${{ needs.build-mac.outputs.run_attempt }}'
)
expect(gate.env.RELEASE_MAC_BUILD_WAIT_DEADLINE_MS).toBe(
'${{ needs.build-mac.outputs.wait_deadline_ms }}'
)
expect(submissions).toHaveLength(2)
for (const submission of submissions) {
expect(steps.indexOf(submission)).toBeGreaterThan(gateIndex)
}
})
it('allows neither signing request without successful macOS verification and upload', () => {
for (const submission of submissions) {
expect(signingAllowed(submission, 'success')).toBe(true)
for (const outcome of ['failure', 'cancelled', 'skipped', '', undefined]) {
expect(signingAllowed(submission, outcome), `${submission.id}: ${outcome}`).toBe(false)
expect(signingAllowed(submission, 'success', 1, 'win', outcome ?? null)).toBe(false)
}
for (const attempt of [2, 3]) {
expect(signingAllowed(submission, 'success', attempt)).toBe(false)
}
for (const platform of ['linux-x64', 'linux-arm64']) {
expect(signingAllowed(submission, 'success', 1, platform)).toBe(false)
}
}
})
it('restores the verifier from the workflow commit when releasing older sources', () => {
const restore = steps.find((step) =>
step.run?.includes(
'git checkout "$WORKFLOW_SHA" -- config/scripts/assert-github-release-is-draft.mjs'
)
)
expect(restore.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
expect(restore.run).toContain('config/scripts/run-release-mac-build-workflow.mjs')
expect(steps.indexOf(restore)).toBeLessThan(steps.indexOf(gate))
})
it('runs the verifier CLI from paths requiring URL encoding, as Windows paths do', async () => {
const directory = realpathSync(mkdtempSync(join(tmpdir(), 'orca signing gate ')))
const script = join(directory, 'mac verifier.mjs')
writeFileSync(script, readFileSync('config/scripts/run-release-mac-build-workflow.mjs'))
try {
const result = await runProcess({
program: process.execPath,
args: [script, 'wait'],
env: {
...process.env,
ORCA_BACKGROUND_LAUNCH: '1',
RELEASE_MAC_BUILD_REF: 'main',
GITHUB_REPOSITORY: 'stablyai/orca',
GITHUB_RUN_ID: '777',
GITHUB_TOKEN: 'test-token',
RELEASE_MAC_BUILD_TAG: 'v1.2.3',
RELEASE_MAC_BUILD_RUN_ID: ''
}
})
expect(result.code).toBe(1)
expect(result.stderr).toContain('RELEASE_MAC_BUILD_RUN_ID is required')
} finally {
rmSync(directory, { recursive: true, force: true })
}
})
})
@@ -1,4 +1,5 @@
import { appendFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
const DEFAULT_API_VERSION = '2026-03-10'
const DEFAULT_TIMEOUT_MINUTES = 90
@@ -344,7 +345,7 @@ export async function executeReleaseMacBuildMode(mode, env = process.env, deps =
throw new Error(`Unknown mac release build mode: ${mode}`)
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
executeReleaseMacBuildMode(process.argv[2]).catch((error) => {
console.error(error)
process.exitCode = 1
@@ -12,14 +12,14 @@ const readWorkflow = (relativePath) => parse(readFileSync(join(projectDir, relat
describe('ref-mirroring vet steps', () => {
it.each(['daily', 'hourly', 'adhoc'])('%s builds only need the current commit', (channel) => {
const job = readWorkflow(`.github/workflows/${channel}-mac-build.yml`).jobs[
`build-${channel}-mac`
]
const { jobs } = readWorkflow(`.github/workflows/${channel}-mac-build.yml`)
const job = jobs[`build-${channel}-mac`]
const identity = channel === 'adhoc' ? jobs['orcad-template-support'] : job
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
expect(checkout.with['fetch-depth']).toBe(1)
expect(job.steps.some((step) => step.run?.includes('gh release list'))).toBe(true)
expect(
job.steps.some((step) => step.run?.includes('ORCA_PUBLISHED_VERSIONS="$published"'))
identity.steps.some((step) => step.run?.includes('ORCA_PUBLISHED_VERSIONS="$published"'))
).toBe(true)
})
@@ -29,9 +29,12 @@ describe('ref-mirroring vet steps', () => {
it.each(['daily', 'hourly', 'adhoc'])(
'%s versions from git tags, not main GitHub releases',
(channel) => {
const step = readWorkflow(`.github/workflows/${channel}-mac-build.yml`).jobs[
`build-${channel}-mac`
].steps.find((candidate) => candidate.name === `Compute ${channel} version`)
const { jobs } = readWorkflow(`.github/workflows/${channel}-mac-build.yml`)
const identity =
channel === 'adhoc' ? jobs['orcad-template-support'] : jobs[`build-${channel}-mac`]
const step = identity.steps.find(
(candidate) => candidate.name === `Compute ${channel} version`
)
expect(step.run).toContain('git/matching-refs/tags/v')
expect(step.run).not.toMatch(
/gh release list[\s\S]*--repo "\$GITHUB_REPOSITORY"[\s\S]*--json tagName/