From cf6bbd30da2aa95029579a83f8749fd164e36347 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:21:25 -0700 Subject: [PATCH] perf(release): overlap Windows builds and gate signing on Mac success (#31032) --- .github/workflows/adhoc-mac-build.yml | 149 +++++++++------- .github/workflows/dev-channel-win-build.yml | 61 ++++++- .github/workflows/dev-channel-win-publish.yml | 107 +++++++++++ .github/workflows/release-cut.yml | 25 ++- .../adhoc-windows-parallelism.test.mjs | 79 ++++++++ .../dev-channel-windows-staging.test.mjs | 137 ++++++++++++++ config/scripts/release-desktop-timing.mjs | 168 ++++++++++++++++++ .../scripts/release-desktop-timing.test.mjs | 120 +++++++++++++ .../release-windows-signing-gate.test.mjs | 114 ++++++++++++ .../run-release-mac-build-workflow.mjs | 3 +- .../workflow-ref-mirror-case-safety.test.mjs | 17 +- 11 files changed, 895 insertions(+), 85 deletions(-) create mode 100644 .github/workflows/dev-channel-win-publish.yml create mode 100644 config/scripts/adhoc-windows-parallelism.test.mjs create mode 100644 config/scripts/dev-channel-windows-staging.test.mjs create mode 100644 config/scripts/release-desktop-timing.mjs create mode 100644 config/scripts/release-desktop-timing.test.mjs create mode 100644 config/scripts/release-windows-signing-gate.test.mjs diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 61967c129d2..3772f4e4d06 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -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' }} diff --git a/.github/workflows/dev-channel-win-build.yml b/.github/workflows/dev-channel-win-build.yml index 19465996486..adebe5da5e7 100644 --- a/.github/workflows/dev-channel-win-build.yml +++ b/.github/workflows/dev-channel-win-build.yml @@ -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 diff --git a/.github/workflows/dev-channel-win-publish.yml b/.github/workflows/dev-channel-win-publish.yml new file mode 100644 index 00000000000..6864d1277d0 --- /dev/null +++ b/.github/workflows/dev-channel-win-publish.yml @@ -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 diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index d2d31da2735..47fa4d90d94 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -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 }} diff --git a/config/scripts/adhoc-windows-parallelism.test.mjs b/config/scripts/adhoc-windows-parallelism.test.mjs new file mode 100644 index 00000000000..9f3dd54feee --- /dev/null +++ b/config/scripts/adhoc-windows-parallelism.test.mjs @@ -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') + } + } + ) +}) diff --git a/config/scripts/dev-channel-windows-staging.test.mjs b/config/scripts/dev-channel-windows-staging.test.mjs new file mode 100644 index 00000000000..b0d81cc4234 --- /dev/null +++ b/config/scripts/dev-channel-windows-staging.test.mjs @@ -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)) + }) +}) diff --git a/config/scripts/release-desktop-timing.mjs b/config/scripts/release-desktop-timing.mjs new file mode 100644 index 00000000000..c6d222fbd52 --- /dev/null +++ b/config/scripts/release-desktop-timing.mjs @@ -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) +} diff --git a/config/scripts/release-desktop-timing.test.mjs b/config/scripts/release-desktop-timing.test.mjs new file mode 100644 index 00000000000..8ab947f46ab --- /dev/null +++ b/config/scripts/release-desktop-timing.test.mjs @@ -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') + }) +}) diff --git a/config/scripts/release-windows-signing-gate.test.mjs b/config/scripts/release-windows-signing-gate.test.mjs new file mode 100644 index 00000000000..3038e9e8cb5 --- /dev/null +++ b/config/scripts/release-windows-signing-gate.test.mjs @@ -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 }) + } + }) +}) diff --git a/config/scripts/run-release-mac-build-workflow.mjs b/config/scripts/run-release-mac-build-workflow.mjs index 4148db46071..5123b81c327 100644 --- a/config/scripts/run-release-mac-build-workflow.mjs +++ b/config/scripts/run-release-mac-build-workflow.mjs @@ -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 diff --git a/config/scripts/workflow-ref-mirror-case-safety.test.mjs b/config/scripts/workflow-ref-mirror-case-safety.test.mjs index 10f02d39886..ca4dba9810d 100644 --- a/config/scripts/workflow-ref-mirror-case-safety.test.mjs +++ b/config/scripts/workflow-ref-mirror-case-safety.test.mjs @@ -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/