mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
perf(release): overlap Windows builds and gate signing on Mac success (#31032)
This commit is contained in:
@@ -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' }}
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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/
|
||||
|
||||
Reference in New Issue
Block a user