mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Build adhoc Mac architectures in parallel (#31415)
* Parallelize adhoc Mac architecture packaging * Include parallel Mac jobs in release timing reports * Cover parallel Mac publication and failure gates * Explicitly scope archive targets to each Mac architecture
This commit is contained in:
@@ -235,13 +235,82 @@ jobs:
|
||||
dev_build: true
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
|
||||
build-adhoc-mac:
|
||||
prepare-adhoc-mac-release:
|
||||
needs: [resolve-ref, orcad-template-support, release-javascript]
|
||||
# Template qualification gates packaging after desktop preparation.
|
||||
if: >-
|
||||
!cancelled() && github.repository == 'stablyai/orca' &&
|
||||
needs.orcad-template-support.result == 'success' &&
|
||||
needs.release-javascript.result == 'success'
|
||||
environment: adhoc-mac-build
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
steps:
|
||||
- name: Mint adhoc 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-adhoc
|
||||
|
||||
- name: Create adhoc release
|
||||
id: release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
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
|
||||
# (zizmor: template-injection).
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
short_sha="${SHA:0:12}"
|
||||
# Why create it up front: electron-builder then uploads into a known tag
|
||||
# rather than inferring one from package.json.
|
||||
#
|
||||
# Why --draft: everything between here and the manifest check is a window
|
||||
# where the release exists but has no installable assets. A draft is
|
||||
# absent from the releases list and from listReleaseBuilds, so a job that
|
||||
# dies in that window — including a hard kill by the job timeout, which
|
||||
# runs no cleanup step at all — leaves something invisible rather than a
|
||||
# tag the picker offers and the download 404s on.
|
||||
gh release create "$TAG" \
|
||||
--repo "$ADHOC_REPO" \
|
||||
--title "$NAME" \
|
||||
--draft \
|
||||
--notes "Adhoc macOS and Windows dev build of \`$REF\` at commit \`$short_sha\`.
|
||||
|
||||
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA), cut by @$ACTOR.
|
||||
|
||||
**Unlanded and unvetted.** This is somebody's branch, not main. No tests
|
||||
ran. The macOS build is signed and notarized like a release, so it installs
|
||||
through Orca's in-app updater and opens without a Gatekeeper prompt — but the
|
||||
branch may never merge, and this build is deleted after $ADHOC_RETAIN_DAYS days.
|
||||
|
||||
**Windows builds are unsigned.** They install and update normally once you
|
||||
are on one, but a signed Stable or RC build cannot install one through the
|
||||
in-app updater — download \`orca-windows-setup.exe\` below and run it once
|
||||
(SmartScreen will warn about an unknown publisher). Every later switch,
|
||||
including back to Stable, works in-app from there."
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
|
||||
build-adhoc-mac-arch:
|
||||
needs: [resolve-ref, orcad-template-support, release-javascript, prepare-adhoc-mac-release]
|
||||
# Template qualification gates packaging after desktop preparation.
|
||||
if: >-
|
||||
!cancelled() && github.repository == 'stablyai/orca' &&
|
||||
needs.orcad-template-support.result == 'success' &&
|
||||
needs.release-javascript.result == 'success' &&
|
||||
needs.prepare-adhoc-mac-release.result == 'success'
|
||||
# Why an environment: it gives the signing/notary/App secrets somewhere to
|
||||
# live that a stale copy of this workflow on an old branch cannot reach.
|
||||
# Referencing it is a no-op until repo settings give it teeth; the intended
|
||||
@@ -250,15 +319,13 @@ jobs:
|
||||
# HOURLY_RELEASE_APP_PRIVATE_KEY into it, then pin its deployment branch
|
||||
# policy to main so only main's copy of this file can read them.
|
||||
environment: adhoc-mac-build
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
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 }}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [x64, arm64]
|
||||
runs-on: blacksmith-12vcpu-macos-15
|
||||
# Why 150: it must exceed the worst case the retry budgets below can produce
|
||||
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
|
||||
@@ -428,7 +495,7 @@ jobs:
|
||||
- name: Wait for the qualified orcad template
|
||||
if: needs.orcad-template-support.outputs.ships == 'true'
|
||||
uses: actions/github-script@v8
|
||||
with: &qualified-template-wait
|
||||
with:
|
||||
script: |
|
||||
const deadline = Date.now() + 30 * 60_000
|
||||
while (Date.now() < deadline) {
|
||||
@@ -461,74 +528,26 @@ jobs:
|
||||
# one hour, everything before this point writes nothing, and the notary round
|
||||
# trip inside the publish step can be tens of minutes. Minting after the build
|
||||
# starts the clock at the first call that actually uses it.
|
||||
- name: Mint adhoc repo token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v2
|
||||
- name: Checkout workflow packaging scripts
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
|
||||
owner: stablyai
|
||||
repositories: orca-adhoc
|
||||
ref: ${{ github.workflow_sha }}
|
||||
path: .workflow-tools
|
||||
sparse-checkout: config/scripts/adhoc-mac-manifests.mjs
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Create adhoc release
|
||||
id: release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
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
|
||||
# (zizmor: template-injection).
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
short_sha="${SHA:0:12}"
|
||||
# Why create it up front: electron-builder then uploads into a known tag
|
||||
# rather than inferring one from package.json.
|
||||
#
|
||||
# Why --draft: everything between here and the manifest check is a window
|
||||
# where the release exists but has no installable assets. A draft is
|
||||
# absent from the releases list and from listReleaseBuilds, so a job that
|
||||
# dies in that window — including a hard kill by the job timeout, which
|
||||
# runs no cleanup step at all — leaves something invisible rather than a
|
||||
# tag the picker offers and the download 404s on.
|
||||
gh release create "$TAG" \
|
||||
--repo "$ADHOC_REPO" \
|
||||
--title "$NAME" \
|
||||
--draft \
|
||||
--notes "Adhoc macOS and Windows dev build of \`$REF\` at commit \`$short_sha\`.
|
||||
|
||||
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA), cut by @$ACTOR.
|
||||
|
||||
**Unlanded and unvetted.** This is somebody's branch, not main. No tests
|
||||
ran. The macOS build is signed and notarized like a release, so it installs
|
||||
through Orca's in-app updater and opens without a Gatekeeper prompt — but the
|
||||
branch may never merge, and this build is deleted after $ADHOC_RETAIN_DAYS days.
|
||||
|
||||
**Windows builds are unsigned.** They install and update normally once you
|
||||
are on one, but a signed Stable or RC build cannot install one through the
|
||||
in-app updater — download \`orca-windows-setup.exe\` below and run it once
|
||||
(SmartScreen will warn about an unknown publisher). Every later switch,
|
||||
including back to Stable, works in-app from there."
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish adhoc macOS artifacts
|
||||
- name: Package and notarize this Mac architecture
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
# Why 45: an attempt is pack + notarize + upload, and the notary queue is
|
||||
# Why 45: an attempt is pack + notarize, and the notary queue is
|
||||
# the unbounded part. Two attempts, because a failed adhoc build has a
|
||||
# person waiting on it who can simply dispatch again.
|
||||
timeout_minutes: 45
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 30
|
||||
command: node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/pinned-node-downloads.mjs darwin-arm64 darwin-x64 && node config/scripts/ensure-native-runtime.mjs --runtime=electron && if [ -f config/scripts/build-mac-notarization-overlap.mjs ]; then ORCA_MAC_ADHOC=1 node config/scripts/build-mac-notarization-overlap.mjs --publish always; else ORCA_MAC_ADHOC=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always; fi
|
||||
command: node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/pinned-node-downloads.mjs darwin-arm64 darwin-x64 && node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_ADHOC=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac dmg zip --${{ matrix.arch }} --publish never
|
||||
env:
|
||||
# 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: ${{ 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.
|
||||
@@ -542,38 +561,133 @@ jobs:
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
# Why: the updater resolves a tag, then fetches latest-mac.yml from it. A
|
||||
# release missing that manifest is a tag the picker offers and the download
|
||||
# 404s on, so fail loudly instead of leaving a broken entry.
|
||||
- name: Verify update manifest published
|
||||
- name: Verify signed and stapled app
|
||||
shell: bash
|
||||
env:
|
||||
MAC_ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
app_dir=dist/mac
|
||||
if [[ "$MAC_ARCH" == arm64 ]]; then app_dir=dist/mac-arm64; fi
|
||||
codesign --verify --deep --strict "$app_dir/Orca.app"
|
||||
xcrun stapler validate "$app_dir/Orca.app"
|
||||
|
||||
- name: Verify this architecture's updater files
|
||||
env:
|
||||
MAC_ARCH: ${{ matrix.arch }}
|
||||
MAC_VERSION: ${{ needs.orcad-template-support.outputs.version }}
|
||||
run: node .workflow-tools/config/scripts/adhoc-mac-manifests.mjs stage "$MAC_ARCH" "$MAC_VERSION" dist .build/mac-manifests
|
||||
|
||||
- name: Mint adhoc 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-adhoc
|
||||
|
||||
- name: Upload verified architecture assets to the draft
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
TAG: ${{ needs.prepare-adhoc-mac-release.outputs.tag }}
|
||||
MAC_ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
assets="$(gh release view "$TAG" --repo "$ADHOC_REPO" --json assets --jq '.assets[].name')"
|
||||
echo "Published assets:"
|
||||
echo "$assets"
|
||||
# Why exit 1 without deleting here: the release is still a draft, so it is
|
||||
# already invisible to users, and the failure handler below owns cleanup.
|
||||
for required in latest-mac.yml; do
|
||||
if ! grep -qx "$required" <<<"$assets"; then
|
||||
echo "::error::Adhoc draft $TAG is missing $required; the updater could not install it."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if ! grep -q '\.zip$' <<<"$assets"; then
|
||||
echo "::error::Adhoc draft $TAG has no ZIP artifact for the updater to download."
|
||||
exit 1
|
||||
fi
|
||||
files=()
|
||||
while IFS= read -r file; do files+=("dist/$file"); done < ".build/mac-manifests/$MAC_ARCH-assets.txt"
|
||||
gh release upload "$TAG" "${files[@]}" --repo "$ADHOC_REPO" --clobber
|
||||
|
||||
- name: Stage verified architecture manifest
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: adhoc-mac-manifest-${{ matrix.arch }}
|
||||
path: .build/mac-manifests/${{ matrix.arch }}.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-adhoc-mac:
|
||||
needs: [resolve-ref, orcad-template-support, prepare-adhoc-mac-release, build-adhoc-mac-arch]
|
||||
if: always() && needs.prepare-adhoc-mac-release.result == 'success'
|
||||
environment: adhoc-mac-build
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 35
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
tag: ${{ needs.prepare-adhoc-mac-release.outputs.tag }}
|
||||
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 }}
|
||||
steps:
|
||||
- name: Mint adhoc 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-adhoc
|
||||
|
||||
- name: Require both architecture jobs
|
||||
env:
|
||||
ARCH_RESULT: ${{ needs.build-adhoc-mac-arch.result }}
|
||||
run: test "$ARCH_RESULT" = success
|
||||
|
||||
- name: Checkout manifest merger
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.workflow_sha }}
|
||||
sparse-checkout: config/scripts/adhoc-mac-manifests.mjs
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download architecture manifests
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: adhoc-mac-manifest-*
|
||||
merge-multiple: true
|
||||
path: .build/mac-manifests
|
||||
|
||||
- name: Merge and verify the updater manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
TAG: ${{ needs.prepare-adhoc-mac-release.outputs.tag }}
|
||||
MAC_VERSION: ${{ needs.orcad-template-support.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release view "$TAG" --repo "$ADHOC_REPO" --json assets > .build/mac-manifests/assets.json
|
||||
node config/scripts/adhoc-mac-manifests.mjs merge "$MAC_VERSION" .build/mac-manifests
|
||||
gh release upload "$TAG" .build/mac-manifests/latest-mac.yml --repo "$ADHOC_REPO" --clobber
|
||||
|
||||
- name: Confirm final template qualification before publication
|
||||
if: needs.orcad-template-support.outputs.ships == 'true'
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
WAIT_FOR_TEMPLATE_COMPLETION: '1'
|
||||
with: *qualified-template-wait
|
||||
with:
|
||||
script: |
|
||||
const deadline = Date.now() + 30 * 60_000
|
||||
while (Date.now() < deadline) {
|
||||
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
|
||||
...context.repo, run_id: context.runId, filter: 'latest', per_page: 100
|
||||
})
|
||||
const lanes = jobs.filter(job => job.name.startsWith('orcad-template / '))
|
||||
const failed = lanes.find(job => job.status === 'completed' &&
|
||||
['failure', 'cancelled', 'timed_out', 'action_required', 'startup_failure'].includes(job.conclusion))
|
||||
if (failed) throw new Error(`Template qualification failed: ${failed.name}`)
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
...context.repo, run_id: context.runId, per_page: 100
|
||||
})
|
||||
const ready = artifacts.some(artifact => artifact.name === 'orcad-template' && !artifact.expired)
|
||||
const completed = lanes.length > 0 && lanes.every(job => job.status === 'completed')
|
||||
if (ready && (process.env.WAIT_FOR_TEMPLATE_COMPLETION !== '1' || completed)) return
|
||||
await new Promise(resolve => setTimeout(resolve, 15_000))
|
||||
}
|
||||
throw new Error('Timed out waiting for the qualified orcad template')
|
||||
|
||||
# Why this is the last mutating step: publishing the draft is what makes the
|
||||
# build visible to listReleaseBuilds. Doing it only after the manifest check
|
||||
@@ -583,7 +697,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
TAG: ${{ needs.prepare-adhoc-mac-release.outputs.tag }}
|
||||
NAME: ${{ needs.orcad-template-support.outputs.name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -601,12 +715,12 @@ jobs:
|
||||
# mid-publish would strand the draft.
|
||||
- name: Discard the draft release on failure
|
||||
if: >-
|
||||
(failure() || cancelled()) && steps.release.outputs.tag != '' &&
|
||||
(failure() || cancelled()) && needs.prepare-adhoc-mac-release.outputs.tag != '' &&
|
||||
steps.publish_live.outcome != 'success'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
TAG: ${{ needs.prepare-adhoc-mac-release.outputs.tag }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# No --cleanup-tag: an unpublished draft never created a git tag.
|
||||
@@ -623,14 +737,14 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
# Protect the tag this run just shipped, so no filter mistake can delete
|
||||
# a build minutes after the person who cut it was told it exists.
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
TAG: ${{ needs.prepare-adhoc-mac-release.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why compute the cutoff in bash rather than with jq's `now`: this runs
|
||||
# once per dispatch, and a fixed epoch makes the threshold visible in the
|
||||
# log when someone asks where their build went.
|
||||
cutoff=$(( $(date -u +%s) - ADHOC_RETAIN_DAYS * 86400 ))
|
||||
echo "Pruning adhoc releases published before $(date -u -r "$cutoff" '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
echo "Pruning adhoc releases published before $(date -u -d "@$cutoff" '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
# --cleanup-tag so pruning does not leave orphan tags with no release or
|
||||
# assets attached. Drafts are excluded: a stale draft is the failure
|
||||
# path's business, not the retention window's.
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('adhoc build pins one commit for the whole run', () => {
|
||||
step.uses?.startsWith('actions/checkout@')
|
||||
)
|
||||
expect(support.with.ref).toBe(PINNED)
|
||||
const vet = jobs['build-adhoc-mac'].steps.find((step) => step.id === 'vetted')
|
||||
const vet = jobs['build-adhoc-mac-arch'].steps.find((step) => step.id === 'vetted')
|
||||
expect(vet.env.REQUESTED_REF).toBe(PINNED)
|
||||
for (const name of ['orcad-template-support', 'orcad-template', 'build-adhoc-mac']) {
|
||||
expect([jobs[name].needs].flat(), name).toContain('resolve-ref')
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
function expectedNames(arch, version) {
|
||||
if (!['arm64', 'x64'].includes(arch) || !/^[0-9][0-9A-Za-z.+-]*$/.test(version)) {
|
||||
throw new Error('Invalid Mac architecture or version')
|
||||
}
|
||||
return [`Orca-${version}${arch === 'arm64' ? '-arm64' : ''}-mac.zip`, `orca-macos-${arch}.dmg`]
|
||||
}
|
||||
|
||||
export function validateManifest(manifest, arch, version) {
|
||||
const names = expectedNames(arch, version)
|
||||
if (
|
||||
manifest.version !== version ||
|
||||
!Array.isArray(manifest.files) ||
|
||||
manifest.files.length !== 2
|
||||
) {
|
||||
throw new Error(`Incomplete or mismatched ${arch} updater manifest`)
|
||||
}
|
||||
for (const name of names) {
|
||||
const matches = manifest.files.filter((file) => file.url === name)
|
||||
if (
|
||||
matches.length !== 1 ||
|
||||
!Number.isSafeInteger(matches[0].size) ||
|
||||
matches[0].size <= 0 ||
|
||||
!/^[A-Za-z0-9+/]{86}==$/.test(matches[0].sha512)
|
||||
) {
|
||||
throw new Error(`Invalid updater entry: ${name}`)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
async function checksum(path) {
|
||||
const hash = createHash('sha512')
|
||||
for await (const chunk of createReadStream(path)) {
|
||||
hash.update(chunk)
|
||||
}
|
||||
return hash.digest('base64')
|
||||
}
|
||||
|
||||
export async function stageManifest(manifest, arch, version, directory) {
|
||||
const names = validateManifest(manifest, arch, version)
|
||||
const assets = []
|
||||
for (const name of names) {
|
||||
const entry = manifest.files.find((file) => file.url === name)
|
||||
if (
|
||||
(await stat(join(directory, name))).size !== entry.size ||
|
||||
(await checksum(join(directory, name))) !== entry.sha512
|
||||
) {
|
||||
throw new Error(`Updater checksum or size mismatch: ${name}`)
|
||||
}
|
||||
assets.push({ name, size: entry.size })
|
||||
const blockmap = `${name}.blockmap`
|
||||
// Both archive types need their matching differential-update sidecar.
|
||||
assets.push({ name: blockmap, size: (await stat(join(directory, blockmap))).size })
|
||||
}
|
||||
return { arch, manifest, assets }
|
||||
}
|
||||
|
||||
export function mergeManifests(records, version, uploaded) {
|
||||
if (records.length !== 2 || new Set(records.map((record) => record.arch)).size !== 2) {
|
||||
throw new Error('Both Mac architectures are required')
|
||||
}
|
||||
for (const arch of ['arm64', 'x64']) {
|
||||
const record = records.find((item) => item.arch === arch)
|
||||
if (!record) {
|
||||
throw new Error(`Missing ${arch} manifest`)
|
||||
}
|
||||
const names = validateManifest(record.manifest, arch, version)
|
||||
const required = names.flatMap((name) => [name, `${name}.blockmap`])
|
||||
if (record.assets.length !== required.length) {
|
||||
throw new Error('Incomplete staged assets')
|
||||
}
|
||||
for (const name of required) {
|
||||
const local = record.assets.filter((asset) => asset.name === name)
|
||||
const remote = uploaded.filter((asset) => asset.name === name)
|
||||
if (
|
||||
local.length !== 1 ||
|
||||
remote.length !== 1 ||
|
||||
!Number.isSafeInteger(local[0].size) ||
|
||||
local[0].size <= 0 ||
|
||||
local[0].size !== remote[0].size
|
||||
) {
|
||||
throw new Error(`Missing or mismatched uploaded asset: ${name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const arm = records.find((record) => record.arch === 'arm64').manifest
|
||||
const intel = records.find((record) => record.arch === 'x64').manifest
|
||||
const canonical = arm.files.find((file) => file.url.endsWith('.zip'))
|
||||
return {
|
||||
...arm,
|
||||
files: [...arm.files, ...intel.files],
|
||||
path: canonical.url,
|
||||
sha512: canonical.sha512
|
||||
}
|
||||
}
|
||||
|
||||
async function main([mode, first, second, third, fourth]) {
|
||||
if (mode === 'stage') {
|
||||
const { parse } = await import('yaml')
|
||||
const manifest = parse(await readFile(join(third, 'latest-mac.yml'), 'utf8'))
|
||||
const record = await stageManifest(manifest, first, second, third)
|
||||
await mkdir(fourth, { recursive: true })
|
||||
await writeFile(join(fourth, `${first}.json`), JSON.stringify(record))
|
||||
await writeFile(
|
||||
join(fourth, `${first}-assets.txt`),
|
||||
`${record.assets.map((asset) => asset.name).join('\n')}\n`
|
||||
)
|
||||
} else if (mode === 'merge') {
|
||||
const records = await Promise.all(
|
||||
['arm64', 'x64'].map(async (arch) =>
|
||||
JSON.parse(await readFile(join(second, `${arch}.json`), 'utf8'))
|
||||
)
|
||||
)
|
||||
const { assets } = JSON.parse(await readFile(join(second, 'assets.json'), 'utf8'))
|
||||
// JSON is YAML 1.2 and preserves every updater metadata field without another dependency.
|
||||
await writeFile(
|
||||
join(second, 'latest-mac.yml'),
|
||||
`${JSON.stringify(mergeManifests(records, first, assets), null, 2)}\n`
|
||||
)
|
||||
} else {
|
||||
throw new Error('Expected stage or merge')
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
main(process.argv.slice(2)).catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { mergeManifests, stageManifest, validateManifest } from './adhoc-mac-manifests.mjs'
|
||||
|
||||
const version = '1.2.3-adhoc.20261021'
|
||||
const content = Buffer.from('signed and stapled archive')
|
||||
const sha512 = createHash('sha512').update(content).digest('base64')
|
||||
function record(arch) {
|
||||
const names = [
|
||||
`Orca-${version}${arch === 'arm64' ? '-arm64' : ''}-mac.zip`,
|
||||
`orca-macos-${arch}.dmg`
|
||||
]
|
||||
return {
|
||||
arch,
|
||||
manifest: {
|
||||
version,
|
||||
path: names[0],
|
||||
sha512,
|
||||
releaseDate: '2026-10-21T00:00:00Z',
|
||||
files: names.map((url) => ({ url, sha512, size: content.length }))
|
||||
},
|
||||
assets: names.flatMap((name) => [
|
||||
{ name, size: content.length },
|
||||
{ name: `${name}.blockmap`, size: 4 }
|
||||
])
|
||||
}
|
||||
}
|
||||
const records = () => [record('arm64'), record('x64')]
|
||||
const uploaded = (items) => items.flatMap((item) => item.assets)
|
||||
|
||||
describe('parallel Mac updater manifests', () => {
|
||||
it('merges both architectures and roundtrips through the updater YAML parser', () => {
|
||||
const items = records()
|
||||
const result = parse(JSON.stringify(mergeManifests(items, version, uploaded(items))))
|
||||
expect(result.files).toHaveLength(4)
|
||||
expect(result.path).toBe(items[0].manifest.path)
|
||||
expect(result.sha512).toBe(sha512)
|
||||
expect(result.releaseDate).toBe(items[0].manifest.releaseDate)
|
||||
})
|
||||
|
||||
it.each(['missing', 'duplicate', 'version', 'path', 'checksum', 'size', 'remote', 'blockmap'])(
|
||||
'refuses incomplete or corrupt publication: %s',
|
||||
(failure) => {
|
||||
const items = records()
|
||||
let assets = uploaded(items).map((asset) => ({ ...asset }))
|
||||
if (failure === 'missing') {
|
||||
items.pop()
|
||||
}
|
||||
if (failure === 'duplicate') {
|
||||
items[1] = record('arm64')
|
||||
}
|
||||
if (failure === 'version') {
|
||||
items[0].manifest.version = '1.0.0'
|
||||
}
|
||||
if (failure === 'path') {
|
||||
items[0].manifest.files[0].url = '../outside.zip'
|
||||
}
|
||||
if (failure === 'checksum') {
|
||||
items[0].manifest.files[0].sha512 = 'bad'
|
||||
}
|
||||
if (failure === 'size') {
|
||||
assets[0].size++
|
||||
}
|
||||
if (failure === 'remote') {
|
||||
assets.push({ ...assets[0] })
|
||||
}
|
||||
if (failure === 'blockmap') {
|
||||
assets = assets.filter((asset) => !asset.name.endsWith('.blockmap'))
|
||||
}
|
||||
expect(() => mergeManifests(items, version, assets)).toThrow()
|
||||
}
|
||||
)
|
||||
|
||||
it('checks the actual archive hashes before permitting upload', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'mac-manifest-'))
|
||||
try {
|
||||
const item = record('arm64')
|
||||
for (const asset of item.assets) {
|
||||
await writeFile(
|
||||
join(directory, asset.name),
|
||||
asset.name.endsWith('.blockmap') ? 'map!' : content
|
||||
)
|
||||
}
|
||||
expect(await stageManifest(item.manifest, item.arch, version, directory)).toEqual(item)
|
||||
await writeFile(join(directory, item.manifest.path), Buffer.alloc(content.length))
|
||||
await expect(stageManifest(item.manifest, item.arch, version, directory)).rejects.toThrow(
|
||||
'checksum or size'
|
||||
)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects extra manifest entries from the wrong architecture', () => {
|
||||
const item = record('x64')
|
||||
item.manifest.files.push(record('arm64').manifest.files[0])
|
||||
expect(() => validateManifest(item.manifest, 'x64', version)).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
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 build = jobs['build-adhoc-mac-arch']
|
||||
const final = jobs['build-adhoc-mac']
|
||||
|
||||
describe('parallel Mac release publication', () => {
|
||||
it('uses isolated architecture workspaces with one shared draft and one publisher', () => {
|
||||
expect(build.strategy).toEqual({ 'fail-fast': false, matrix: { arch: ['x64', 'arm64'] } })
|
||||
expect(build.needs).toContain('prepare-adhoc-mac-release')
|
||||
expect(final.needs).toContain('build-adhoc-mac-arch')
|
||||
expect(build.steps.some((step) => step.run?.includes('gh release create'))).toBe(false)
|
||||
expect(build.steps.some((step) => step.run?.includes('--draft=false'))).toBe(false)
|
||||
const upload = build.steps.find(
|
||||
(step) => step.name === 'Upload verified architecture assets to the draft'
|
||||
)
|
||||
expect(upload.env.TAG).toBe('${{ needs.prepare-adhoc-mac-release.outputs.tag }}')
|
||||
expect(upload.run).not.toContain('latest-mac.yml')
|
||||
expect(build.steps.find((step) => step.name === 'Verify signed and stapled app').run).toContain(
|
||||
'codesign --verify --deep --strict'
|
||||
)
|
||||
expect(build.steps.find((step) => step.name === 'Verify signed and stapled app').run).toContain(
|
||||
'xcrun stapler validate'
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['failure', 'cancelled', 'skipped', 'success'])(
|
||||
'runs the final gate and cleanup after architecture result %s',
|
||||
(result) => {
|
||||
const needs = {
|
||||
'prepare-adhoc-mac-release': { result: 'success' },
|
||||
'build-adhoc-mac-arch': { result }
|
||||
}
|
||||
expect(final.if).toContain('always()')
|
||||
expect(
|
||||
compileAggregateExpression(final.if.replace('always()', 'true'))({ needs }).coerceString()
|
||||
).toBe('true')
|
||||
const gate = final.steps.findIndex((step) => step.name === 'Require both architecture jobs')
|
||||
const merge = final.steps.findIndex(
|
||||
(step) => step.name === 'Merge and verify the updater manifest'
|
||||
)
|
||||
const publish = final.steps.findIndex((step) => step.id === 'publish_live')
|
||||
expect(final.steps[gate].env.ARCH_RESULT).toBe('${{ needs.build-adhoc-mac-arch.result }}')
|
||||
expect(final.steps[gate].run).toBe('test "$ARCH_RESULT" = success')
|
||||
expect(gate).toBeLessThan(merge)
|
||||
expect(merge).toBeLessThan(publish)
|
||||
const cleanup = final.steps.find(
|
||||
(step) => step.name === 'Discard the draft release on failure'
|
||||
)
|
||||
expect(cleanup.if).toContain('failure() || cancelled()')
|
||||
expect(cleanup.if).toContain("steps.publish_live.outcome != 'success'")
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -5,7 +5,8 @@ import { parse } from 'yaml'
|
||||
const adhoc = parse(readFileSync('.github/workflows/adhoc-mac-build.yml', 'utf8'))
|
||||
const publisher = parse(readFileSync('.github/workflows/dev-channel-win-publish.yml', 'utf8'))
|
||||
const windows = parse(readFileSync('.github/workflows/dev-channel-win-build.yml', 'utf8'))
|
||||
const macSteps = adhoc.jobs['build-adhoc-mac'].steps
|
||||
const macSteps = adhoc.jobs['build-adhoc-mac-arch'].steps
|
||||
const finalSteps = adhoc.jobs['build-adhoc-mac'].steps
|
||||
const publishSteps = publisher.jobs['publish-win'].steps
|
||||
const macWait = publishSteps.find((step) => step.name === 'Wait for successful macOS publication')
|
||||
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor
|
||||
@@ -120,7 +121,7 @@ describe.each([
|
||||
})
|
||||
|
||||
it('keeps the release a draft until qualification and its cleanup finish', async () => {
|
||||
const final = macSteps.find(
|
||||
const final = finalSteps.find(
|
||||
(step) => step.name === 'Confirm final template qualification before publication'
|
||||
)
|
||||
const artifact = [{ name: 'orcad-template', expired: false }]
|
||||
@@ -130,8 +131,8 @@ it('keeps the release a draft until qualification and its cleanup finish', async
|
||||
])
|
||||
await result
|
||||
expect(calls.filter((call) => call.endpoint === 'artifacts')).toHaveLength(2)
|
||||
expect(macSteps.indexOf(final)).toBeLessThan(
|
||||
macSteps.findIndex((step) => step.id === 'publish_live')
|
||||
expect(finalSteps.indexOf(final)).toBeLessThan(
|
||||
finalSteps.findIndex((step) => step.id === 'publish_live')
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ 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 mac = jobs['build-adhoc-mac-arch']
|
||||
const final = jobs['build-adhoc-mac']
|
||||
const windows = jobs['build-adhoc-win']
|
||||
const publish = jobs['publish-adhoc-win']
|
||||
const bundleWorkflow = parse(readFileSync('.github/workflows/release-javascript.yml', 'utf8'))
|
||||
@@ -17,6 +18,7 @@ function qualification(template = 'success', ships = 'true') {
|
||||
'resolve-ref': { result: 'success', outputs: { sha: 'a'.repeat(40) } },
|
||||
'orcad-template-support': { result: 'success', outputs: { ships } },
|
||||
'release-javascript': { result: 'success' },
|
||||
'prepare-adhoc-mac-release': { result: 'success' },
|
||||
'orcad-template': { result: template }
|
||||
}
|
||||
}
|
||||
@@ -24,14 +26,14 @@ function qualification(template = 'success', ships = 'true') {
|
||||
|
||||
describe('adhoc Windows compilation overlaps Mac without publishing early', () => {
|
||||
it('starts both platforms during qualification without a Mac dependency', () => {
|
||||
expect(windows.needs).toEqual(mac.needs)
|
||||
expect(mac.needs).toEqual([...windows.needs, 'prepare-adhoc-mac-release'])
|
||||
expect(mac.needs).not.toContain('orcad-template')
|
||||
expect(windows.needs).not.toContain('build-adhoc-mac')
|
||||
expect(windows.with.stage_only).toBe(true)
|
||||
expect(windows.with.fast_release).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.version).toBe(final.outputs.version)
|
||||
expect(windows.with.orcad_template).toBe(
|
||||
"${{ needs.orcad-template-support.outputs.ships == 'true' }}"
|
||||
)
|
||||
@@ -39,9 +41,10 @@ describe('adhoc Windows compilation overlaps Mac without publishing early', () =
|
||||
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
|
||||
)
|
||||
expect(
|
||||
jobs['prepare-adhoc-mac-release'].steps.find((step) => step.name === 'Create adhoc release')
|
||||
.env.TAG
|
||||
).toBe(windows.with.tag)
|
||||
})
|
||||
|
||||
it('builds portable JavaScript alongside qualification and restores it on both platforms', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ const { Platform } = require('app-builder-lib')
|
||||
const { PlatformPackager } = require('app-builder-lib/out/platformPackager')
|
||||
const { ArchiveTarget } = require('app-builder-lib/out/targets/ArchiveTarget')
|
||||
const { DmgTarget } = require('dmg-builder/out/dmg')
|
||||
const { computeArchToTargetNamesMap } = require('app-builder-lib/out/targets/targetFactory')
|
||||
const { AsyncTaskManager } = require('builder-util')
|
||||
const { CancellationToken } = require('builder-util-runtime')
|
||||
const workflow = (name) => parse(readFileSync(`.github/workflows/${name}-mac-build.yml`, 'utf8'))
|
||||
@@ -60,6 +61,25 @@ it('the pinned builder reaches both signed slices before either real archive tar
|
||||
expect(events.filter((event) => event.startsWith('pass:'))).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('explicit archive targets keep the configured dual-architecture defaults out of each matrix job', () => {
|
||||
const packager = {
|
||||
platformSpecificBuildOptions: {
|
||||
target: [
|
||||
{ target: 'dmg', arch: ['x64', 'arm64'] },
|
||||
{ target: 'zip', arch: ['x64', 'arm64'] }
|
||||
]
|
||||
}
|
||||
}
|
||||
for (const arch of [1, 3]) {
|
||||
const targets = computeArchToTargetNamesMap(
|
||||
new Map([[arch, ['dmg', 'zip']]]),
|
||||
packager,
|
||||
Platform.MAC
|
||||
)
|
||||
expect([...targets]).toEqual([[arch, ['dmg', 'zip']]])
|
||||
}
|
||||
})
|
||||
|
||||
describe('macOS build workflow policy', () => {
|
||||
it.each(['release', 'adhoc', 'daily', 'hourly'])(
|
||||
'%s uses the guarded builder, preserving the serial fallback for older source refs',
|
||||
@@ -74,13 +94,20 @@ describe('macOS build workflow policy', () => {
|
||||
step.with?.command?.includes('electron-builder --config')
|
||||
)
|
||||
const command = step.with.command
|
||||
expect(command).toContain('if [ -f config/scripts/build-mac-notarization-overlap.mjs ]')
|
||||
expect(command).toContain(
|
||||
`ORCA_MAC_${channel.toUpperCase()}=1 node config/scripts/build-mac-notarization-overlap.mjs`
|
||||
)
|
||||
expect(command).toContain(
|
||||
`else ORCA_MAC_${channel.toUpperCase()}=1 pnpm exec electron-builder`
|
||||
)
|
||||
if (channel === 'adhoc') {
|
||||
expect(job.strategy).toEqual({ 'fail-fast': false, matrix: { arch: ['x64', 'arm64'] } })
|
||||
expect(command).toContain('--mac dmg zip --${{ matrix.arch }} --publish never')
|
||||
expect(command).toContain('ORCA_MAC_ADHOC=1 pnpm exec electron-builder')
|
||||
expect(command).not.toContain('build-mac-notarization-overlap.mjs')
|
||||
} else {
|
||||
expect(command).toContain('if [ -f config/scripts/build-mac-notarization-overlap.mjs ]')
|
||||
expect(command).toContain(
|
||||
`ORCA_MAC_${channel.toUpperCase()}=1 node config/scripts/build-mac-notarization-overlap.mjs`
|
||||
)
|
||||
expect(command).toContain(
|
||||
`else ORCA_MAC_${channel.toUpperCase()}=1 pnpm exec electron-builder`
|
||||
)
|
||||
}
|
||||
expect(step.with.max_attempts).toBeGreaterThan(1)
|
||||
for (const key of [
|
||||
'CSC_LINK',
|
||||
|
||||
@@ -71,15 +71,22 @@ export function summarizeReleaseDesktopTiming(run) {
|
||||
)
|
||||
const jobs = run.jobs.filter((job) => job.conclusion !== 'skipped')
|
||||
const mac = jobs.find((job) => job.name === 'build-adhoc-mac')
|
||||
const macJobs = jobs.filter((job) => /^build-adhoc-mac(?:-arch)?(?: \(|$)/.test(job.name))
|
||||
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')
|
||||
const template = jobs.find(
|
||||
(job) => job.name.startsWith('orcad-template / ') && job.name.endsWith('/ desktop_template')
|
||||
)
|
||||
assert(mac && windows.length > 0 && template, 'Missing Mac, Windows, or qualified template job')
|
||||
assert(
|
||||
[mac, ...windows, template].every((job) => job.conclusion === 'success'),
|
||||
[...macJobs, ...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 macIntervals = macJobs.map(interval)
|
||||
const macInterval = {
|
||||
start: Math.min(...macIntervals.map(({ start }) => start)),
|
||||
end: Math.max(...macIntervals.map(({ end }) => end))
|
||||
}
|
||||
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))
|
||||
@@ -113,7 +120,7 @@ export function summarizeReleaseDesktopTiming(run) {
|
||||
/^(?:Stage Windows artifacts for gated publishing|Download the staged Windows artifacts)$/
|
||||
)
|
||||
},
|
||||
desktopJobs: [mac, ...windows].map((job) => {
|
||||
desktopJobs: [...macJobs, ...windows].map((job) => {
|
||||
const { start, end } = interval(job)
|
||||
return {
|
||||
name: job.name,
|
||||
|
||||
@@ -106,6 +106,19 @@ describe('release desktop timing evidence', () => {
|
||||
expect(report.windowsPhases.artifactHandoff.seconds).toBe(20)
|
||||
})
|
||||
|
||||
it('includes parallel Mac packaging before the final publication job', () => {
|
||||
const input = run([job('build-adhoc-win / build-win', 20, 80)], 90, 100)
|
||||
input.jobs[1].name = 'orcad-template / qualify / desktop_template'
|
||||
input.jobs.push(job('build-adhoc-mac-arch (x64)', 15, 85, 25))
|
||||
input.jobs.push(job('build-adhoc-mac-arch (arm64)', 15, 90, 30))
|
||||
const report = summarizeReleaseDesktopTiming(input)
|
||||
expect(report.measuredSeconds.macJob).toBe(75)
|
||||
expect(report.measuredSeconds.desktopJobOverlap).toBe(55)
|
||||
expect(report.desktopJobs).toHaveLength(4)
|
||||
input.jobs.at(-1).conclusion = 'failure'
|
||||
expect(() => summarizeReleaseDesktopTiming(input)).toThrow('Desktop or template job failed')
|
||||
})
|
||||
|
||||
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(
|
||||
|
||||
@@ -13,11 +13,15 @@ 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 { jobs } = readWorkflow(`.github/workflows/${channel}-mac-build.yml`)
|
||||
const job = jobs[`build-${channel}-mac`]
|
||||
const job = jobs[channel === 'adhoc' ? 'build-adhoc-mac-arch' : `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(
|
||||
(channel === 'adhoc' ? jobs['build-adhoc-mac'] : job).steps.some((step) =>
|
||||
step.run?.includes('gh release list')
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
identity.steps.some((step) => step.run?.includes('ORCA_PUBLISHED_VERSIONS="$published"'))
|
||||
).toBe(true)
|
||||
@@ -31,7 +35,9 @@ describe('ref-mirroring vet steps', () => {
|
||||
(channel) => {
|
||||
const { jobs } = readWorkflow(`.github/workflows/${channel}-mac-build.yml`)
|
||||
const identity =
|
||||
channel === 'adhoc' ? jobs['orcad-template-support'] : jobs[`build-${channel}-mac`]
|
||||
channel === 'adhoc'
|
||||
? jobs['orcad-template-support']
|
||||
: jobs[channel === 'adhoc' ? 'build-adhoc-mac-arch' : `build-${channel}-mac`]
|
||||
const step = identity.steps.find(
|
||||
(candidate) => candidate.name === `Compute ${channel} version`
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ const readWorkflow = (name) => parse(readFileSync(`.github/workflows/${name}.yml
|
||||
const windowsVet = readWorkflow('dev-channel-win-build').jobs['build-win'].steps.find(
|
||||
(step) => step.id === 'vetted'
|
||||
)
|
||||
const macSteps = readWorkflow('adhoc-mac-build').jobs['build-adhoc-mac'].steps
|
||||
const macSteps = readWorkflow('adhoc-mac-build').jobs['build-adhoc-mac-arch'].steps
|
||||
const macVet = macSteps.find((step) => step.id === 'vetted')
|
||||
const macCheckout = macSteps.find((step) => step.name === 'Checkout the requested ref')
|
||||
const directory = mkdtempSync(join(tmpdir(), 'workflow-ref-reachability-'))
|
||||
|
||||
Reference in New Issue
Block a user