Files
Marc ChanandGitHub 2ba458503f [OPEND-4236] perf(pack): remove redundant checks and defer cache persistence (#8955)
* fix(pack): notarize macOS builds only once

* perf(pack): separate type validation and defer bounded Windows caches

* test(e2e): bound BuildKit cleanup job away from pack cache save

The deferred Windows pack-cache job checkouts the repo, so slicing
clean-closed-pr-buildkit to EOF made the no-checkout assertion fail.

Generated-By: looper 0.16.1 (runner=fixer, agent=omp)

* test(ci): bound cache cleanup assertions to their job

* fix(ci): preserve cache provenance when the release branch advances

* perf(ci): reduce compression work in Windows cache handoff

* fix(ci): align Windows cache restore and persistence paths

* perf(ci): retain latest complete Windows artifacts for repeat builds

* fix(ci): keep newer pack caches when ancestor saves finish later

Prefix pruning in cache-maintenance used cache creation time, so an older ancestor finishing after a newer same-ref release deleted that newer cache. Prune only when the validated source SHA is the current commit.

Generated-By: looper 0.16.1 (runner=fixer, agent=omp)
2026-10-22 03:58:10 +00:00

1765 lines
89 KiB
YAML

name: release-prerelease
on:
workflow_dispatch:
inputs:
ref:
description: "Optional git ref to build. Empty builds the dispatched ref."
required: false
type: string
default: ""
release_version:
description: "Base version (x.y.z). Required when ref is not release/vX.Y.Z."
required: false
type: string
default: ""
amr_profile:
description: "Optional AMR profile baked into the build (test or feature-test point the packaged app at that profile's Vela backend and enable the workspace-team transport). Empty leaves the default (prod)."
required: false
type: string
default: ""
enable_mac_x64:
description: "Build and publish macOS x64 (Intel) artifacts. ON by default: stable ships Intel and stable is gated on validated prerelease artifacts, so an Intel-less prerelease cannot be promoted. The switch stays so a run that is explicitly not about Intel can buy back the ~12 min it adds to the critical path (~33 min on the macos-15-intel fleet against ~21 min everywhere else); turning it off costs that build its promotion eligibility."
required: false
type: boolean
default: true
enable_smoke:
description: "Run the packaged smoke suites (mac arm64, mac Intel, Windows). ON by default. Smoke no longer runs inside the build jobs and now costs this pipeline nothing but a dispatch: release-prerelease-smoke.yml picks it up after the packages are in R2 and re-downloads them from R2, so it exercises the artifact a user actually receives. Turn it off only to skip those runners entirely."
required: false
type: boolean
default: true
enable_tests:
description: "Dispatch the code test suites (P0 Functional E2E, E2E Vitest, daemon shards, verify) into release-prerelease-tests.yml. ON by default. They run against this build's commit in a workflow of their own, gate nothing here, and cost this pipeline only the seconds the dispatch takes."
required: false
type: boolean
default: true
mac_sign_mode:
description: "macOS signing depth for both mac lanes. 'sign-only' (default) codesigns with the Apple certificate but skips the Apple notary round-trip, which is minutes of waiting on Apple that no prerelease consumer needs. 'notarize' also notarizes and staples — use it for the last prerelease before a stable promotion, since stable ships notarized. 'no' skips signing entirely and will FAIL the stable promotion gate, which requires platforms.mac.signed == true."
required: true
type: choice
options:
- "no"
- "sign-only"
- "notarize"
default: "sign-only"
win_x64_smoke_mode:
description: "Windows x64 smoke coverage, applied only when enable_smoke is on. enable_smoke decides WHETHER packaged smoke runs; this input decides HOW DEEP the Windows one goes."
required: true
type: choice
options:
- skip
- core
- full
default: core
win_x64_update_metadata_url:
description: "Optional external updater metadata URL for full Windows x64 payload smoke."
required: false
type: string
default: ""
win_x64_update_target_version:
description: "Optional external updater target version expected by full Windows x64 payload smoke."
required: false
type: string
default: ""
workflow_call:
inputs:
ref:
description: "Optional git ref to build. Empty builds the caller's ref."
required: false
type: string
default: ""
release_version:
description: "Base version (x.y.z). Required when ref is not release/vX.Y.Z."
required: false
type: string
default: ""
amr_profile:
description: "Optional AMR profile baked into the build (test or feature-test enable the workspace-team transport by pointing at that profile's Vela backend). Empty leaves the default (prod)."
required: false
type: string
default: ""
enable_mac_x64:
description: "Build and publish macOS x64 (Intel) artifacts. ON by default: stable ships Intel and stable is gated on validated prerelease artifacts, so an Intel-less prerelease cannot be promoted. The switch stays so a run that is explicitly not about Intel can buy back the ~12 min it adds to the critical path; turning it off costs that build its promotion eligibility."
required: false
type: boolean
default: true
enable_smoke:
description: "Run the packaged smoke suites (mac arm64, mac Intel, Windows). ON by default. Smoke no longer runs inside the build jobs: release-prerelease-smoke.yml picks it up after the packages are in R2 and re-downloads them from R2, so it exercises the artifact a user actually receives."
required: false
type: boolean
default: true
enable_tests:
description: "Dispatch the code test suites (P0 Functional E2E, E2E Vitest, daemon shards, verify) into release-prerelease-tests.yml. ON by default. They run against this build's commit in a workflow of their own and gate nothing here."
required: false
type: boolean
default: true
mac_sign_mode:
description: "macOS signing depth for both mac lanes: no / sign-only / notarize. Defaults to sign-only — codesigned but not sent to Apple's notary service. Use notarize for the last prerelease before a stable promotion. 'no' fails the stable promotion gate, which requires platforms.mac.signed == true."
required: false
type: string
default: "sign-only"
win_x64_smoke_mode:
description: "Windows x64 smoke coverage, applied only when enable_smoke is on: skip, core, or full."
required: false
type: string
default: "core"
win_x64_update_metadata_url:
description: "Optional external updater metadata URL for full Windows x64 payload smoke."
required: false
type: string
default: ""
win_x64_update_target_version:
description: "Optional external updater target version expected by full Windows x64 payload smoke."
required: false
type: string
default: ""
outputs:
release_version:
description: "The prerelease version that was built."
value: ${{ jobs.metadata.outputs.release_version }}
channel:
description: "The prerelease channel."
value: ${{ jobs.metadata.outputs.channel }}
branch:
description: "The branch that was built."
value: ${{ jobs.metadata.outputs.branch }}
commit:
description: "The commit SHA that was built."
value: ${{ jobs.metadata.outputs.commit }}
previous_commit:
description: "Commit SHA of the previously published prerelease; empty on cold start."
value: ${{ jobs.metadata.outputs.previous_commit }}
# --- Advisory signals ------------------------------------------------
# Nothing below gates publication.
#
# The code tests and the packaged mac/Windows smoke no longer run in this
# workflow at all: they are dispatched into release-prerelease-tests.yml
# and release-prerelease-smoke.yml, which have concurrency groups of their
# own. That is the whole point — a validation job that outlives `publish`
# used to hold this workflow open, and this workflow's concurrency group
# is a single repository-wide string, so the next prerelease could not
# even START until the last one's tests finished. Their results reach a
# human through the progressive Feishu card (release-prerelease-card.yml),
# which watches all three runs, not through outputs here.
#
# Linux keeps its in-job smoke because the whole Linux lane is opt-in
# behind vars.ENABLE_STABLE_LINUX and is not on anyone's critical path.
linux_x64_smoke_result:
description: "Linux AppImage smoke outcome. Advisory; `skipped` unless vars.ENABLE_STABLE_LINUX is on."
value: ${{ jobs.build_linux.outputs.smoke_result }}
version_metadata_url:
description: "Public R2 URL of this build's version metadata.json. This is the authoritative \"did a package actually ship?\" signal — it is non-empty only when the publish job wrote metadata. Do NOT read the overall workflow conclusion for that: it now goes red whenever an advisory test job fails, even though the package shipped."
value: ${{ jobs.publish.outputs.version_metadata_url }}
mac_arm64_url:
description: "macOS arm64 dmg download URL."
value: ${{ jobs.publish.outputs.mac_arm64_url }}
mac_intel_url:
description: "macOS x64 dmg download URL."
value: ${{ jobs.publish.outputs.mac_intel_url }}
win_url:
description: "Windows x64 installer download URL."
value: ${{ jobs.publish.outputs.win_url }}
linux_url:
description: "Linux x64 AppImage download URL."
value: ${{ jobs.publish.outputs.linux_url }}
permissions:
actions: write
contents: read
concurrency:
group: open-design-release-prerelease
cancel-in-progress: false
env:
OPEN_DESIGN_TELEMETRY_RELAY_URL: ${{ vars.OPEN_DESIGN_TELEMETRY_RELAY_URL }}
# PostHog product-analytics ingest. Defined as repository secret + var
# so official builds ship with analytics enabled; PR builds and forks
# without these run the daemon in no-op analytics mode (events never
# leave the user's machine, /api/analytics/config returns enabled=false).
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
# PostHog Error tracking sourcemap upload. Personal API key (phx_...) and
# project ID let tools-pack's web-sourcemaps step ship browser sourcemaps
# to PostHog after `next build` and before the .map files are stripped
# from the packaged bundle. Missing in PR/fork builds → upload is skipped
# and the helper still strips .map to keep source out of the installer.
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
POSTHOG_CLI_PROJECT_ID: ${{ vars.POSTHOG_CLI_PROJECT_ID }}
# Workspace Team gate for packaged prerelease builds. tools-pack reads
# OPEN_DESIGN_AMR_PROFILE and bakes OD_VELA_WEB_URL into open-design-config.json;
# both are required for the packaged daemon to enable the workspace-team
# transport (see apps/packaged/src/workspace-team.ts).
#
# Every profile now resolves an origin, production included: prod's Vela
# backend serves the feature (WORKSPACE_TEAM_ENABLED plus a configured
# resource-hub blob store), so a prod build ships the transports enabled.
# An empty amr_profile still yields no origin, which the packaged gate treats
# as dormant — a build that forgets the profile degrades to local-only rather
# than pointing at an unknown backend.
#
# Origins stay in repository secrets rather than literals: the non-prod AMR
# environments are internal deployments and this repository is public.
# Secrets read here: VELA_WEB_URL_FEATURE_TEST, VELA_WEB_URL_TEST,
# VELA_WEB_URL_PROD.
OPEN_DESIGN_AMR_PROFILE: ${{ inputs.amr_profile }}
OD_VELA_WEB_URL: ${{ inputs.amr_profile == 'feature-test' && secrets.VELA_WEB_URL_FEATURE_TEST || inputs.amr_profile == 'test' && secrets.VELA_WEB_URL_TEST || inputs.amr_profile == 'prod' && secrets.VELA_WEB_URL_PROD || '' }}
OD_VELA_WEB_URL_FEATURE_TEST: ${{ secrets.VELA_WEB_URL_FEATURE_TEST }}
OD_VELA_WEB_URL_TEST: ${{ secrets.VELA_WEB_URL_TEST }}
OD_VELA_WEB_URL_PROD: ${{ secrets.VELA_WEB_URL_PROD }}
jobs:
metadata:
name: Prepare prerelease metadata
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
OPEN_DESIGN_PRERELEASE_METADATA_URL: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}/prerelease/latest/metadata.json
OPEN_DESIGN_RELEASE_CHANNEL: prerelease
OPEN_DESIGN_STABLE_VERSION: ${{ inputs.release_version }}
outputs:
base_version: ${{ steps.prerelease.outputs.base_version }}
branch: ${{ steps.prerelease.outputs.branch }}
channel: ${{ steps.prerelease.outputs.channel }}
commit: ${{ steps.prerelease.outputs.commit }}
github_release_enabled: ${{ steps.prerelease.outputs.github_release_enabled }}
latest_stable: ${{ steps.prerelease.outputs.latest_stable }}
previous_commit: ${{ steps.prev.outputs.previous_commit }}
prerelease_number: ${{ steps.prerelease.outputs.prerelease_number }}
prerelease_version: ${{ steps.prerelease.outputs.prerelease_version }}
release_number: ${{ steps.prerelease.outputs.release_number }}
release_name: ${{ steps.prerelease.outputs.release_name }}
release_version: ${{ steps.prerelease.outputs.release_version }}
state_source: ${{ steps.prerelease.outputs.state_source }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
ref: ${{ inputs.ref != '' && inputs.ref || github.ref }}
# Shallow. Nothing in this workflow reads git history: the built
# commit arrives as an explicit input/env (RELEASE_COMMIT), `pnpm
# guard` uses `git ls-files` (index, not history), and tools-pack /
# tools-release never shell out to git. A full clone of this
# repository is a 2.2 GiB pack and cost 44-102s per job.
fetch-depth: 1
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Resolve built commit
run: echo "BUILT_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- name: Capture previous prerelease commit
id: prev
run: |
set -euo pipefail
prev="$(curl -fsSL "$OPEN_DESIGN_PRERELEASE_METADATA_URL" | jq -r '.github.commit // ""' 2>/dev/null || true)"
echo "previous prerelease commit: ${prev:-<none>}"
echo "previous_commit=$prev" >> "$GITHUB_OUTPUT"
- name: Prepare prerelease release metadata
id: prerelease
env:
GITHUB_SHA: ${{ env.BUILT_SHA }}
GITHUB_REF_NAME: ${{ inputs.ref != '' && inputs.ref || github.ref_name }}
run: pnpm exec tools-release prepare prerelease
- name: Validate R2 prerelease access
env:
GITHUB_SHA: ${{ env.BUILT_SHA }}
R2_ACCESS_PROBE_NAME: release-prerelease
RELEASE_CHANNEL: prerelease
RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
RELEASE_STORAGE_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
RELEASE_STORAGE_ENDPOINT: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
RELEASE_STORAGE_REGION: auto
RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
run: pnpm exec tools-release check-storage
dispatch_validation:
name: Dispatch prerelease validation
# Fire-and-forget, and deliberately absent from every other job's `needs`.
#
# This workflow holds ONE repository-wide concurrency group
# (`open-design-release-prerelease`, cancel-in-progress: false), so any job
# that outlives `publish` keeps that group held and stops the NEXT
# prerelease from STARTING — not from publishing, from starting. The test
# suites were doing exactly that. Handing them to workflows of their own
# costs this pipeline the seconds it takes to POST a dispatch.
#
# A dispatch failure is loud (this job goes red) and harmless: nothing
# depends on this job, so `publish` cannot see it.
#
# Red is not, however, a channel anyone watches. The progressive card is the
# ONLY prerelease notification left, so a card that was never dispatched is
# a release the team never hears about. The last steps below are the ping
# for that, and they live in this job rather than a job of their own
# precisely because nothing may depend on the dispatchers.
needs: metadata
if: ${{ needs.metadata.result == 'success' }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
# Read only by the last step, which alerts when the card was not
# dispatched. Deliberately the OTHER bot: the card posts through the
# Feishu application bot below, so an application credential that expired
# (or was emptied, which silently skips the dispatch step) must not take
# the alert down with it.
FEISHU_SIGN_SECRET: ${{ secrets.FEISHU_RELEASE_SIGN_SECRET }}
FEISHU_WEBHOOK: ${{ secrets.FEISHU_RELEASE_WEBHOOK }}
GH_REPO: ${{ github.repository }}
PRIMARY_REF: ${{ needs.metadata.outputs.branch }}
FALLBACK_REF: ${{ github.event.repository.default_branch || 'main' }}
# `secrets` is not a context an `if:` may read, so the card dispatch
# checks for its own credentials through env instead. A fork, or a
# repository without the Feishu app configured, simply skips the card.
FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}
FEISHU_RELEASE_CHAT_ID: ${{ secrets.FEISHU_RELEASE_CHAT_ID }}
steps:
- name: Checkout dispatch helper
id: checkout
uses: actions/checkout@v6.0.2
with:
# Deliberately the ref this workflow was loaded from, NOT the built
# commit. The helper is infrastructure that ships with this file, so
# the two are always in step; pinning it to the built commit would
# break the moment a release branch carried an older .github tree.
fetch-depth: 1
- name: Dispatch prerelease code tests
id: tests_dispatch
if: ${{ inputs.enable_tests }}
env:
COMMIT: ${{ needs.metadata.outputs.commit }}
VERSION: ${{ needs.metadata.outputs.release_version }}
run: |
bash .github/scripts/release/dispatch-validation.sh release-prerelease-tests.yml \
-f "commit=$COMMIT" \
-f "version=$VERSION" \
-f "origin_run_id=$GITHUB_RUN_ID"
- name: Dispatch progressive Feishu card
id: card_dispatch
if: ${{ env.FEISHU_APP_ID != '' && env.FEISHU_RELEASE_CHAT_ID != '' }}
env:
BRANCH: ${{ needs.metadata.outputs.branch }}
COMMIT: ${{ needs.metadata.outputs.commit }}
EXPECT_SMOKE: ${{ inputs.enable_smoke }}
EXPECT_TESTS: ${{ inputs.enable_tests }}
PREVIOUS_COMMIT: ${{ needs.metadata.outputs.previous_commit }}
VERSION: ${{ needs.metadata.outputs.release_version }}
run: |
bash .github/scripts/release/dispatch-validation.sh release-prerelease-card.yml \
-f "version=$VERSION" \
-f "commit=$COMMIT" \
-f "branch=$BRANCH" \
-f "previous_commit=$PREVIOUS_COMMIT" \
-f "origin_run_id=$GITHUB_RUN_ID" \
-f "expect_tests=$EXPECT_TESTS" \
-f "expect_smoke=$EXPECT_SMOKE"
# Only reached when the dispatch above actually succeeded — the helper
# exits non-zero on every ref it could not dispatch on, and `run:`
# blocks stop at the first failure. Empty therefore covers every way
# the card fails to start: the helper could not dispatch on any ref,
# an earlier step in this job died, or this step was skipped because
# the application bot's credentials are missing.
echo "dispatched=true" >> "$GITHUB_OUTPUT"
# ---- Fallback when the card never started --------------------------
# `always()` so a failed dispatch above still reaches these; gated on the
# checkout because both scripts come from the working tree. They run on
# every prerelease and stay silent on the healthy ones — the composer
# refuses to emit `alert=true` when the card was dispatched, which is the
# same rule this repository cannot afford to get wrong: a fallback that
# also posts on good days is just a second card.
#
# This ping is necessarily early. It fires beside the build, before any
# package exists, so it reports where the version metadata WILL appear
# instead of claiming a publication verdict it cannot have yet. Once the
# card workflow IS running, release-prerelease-card.yml's own
# `fallback_notice` job owns the rest of the lane — the two are mutually
# exclusive, so a release can never draw both.
- name: Setup Node.js for the fallback notifier
if: >-
${{
always() && github.repository == 'nexu-io/open-design' &&
steps.checkout.outcome == 'success'
}}
uses: actions/setup-node@v6
with:
node-version: 24
- name: Compose the fallback notice
id: notice
if: >-
${{
always() && github.repository == 'nexu-io/open-design' &&
steps.checkout.outcome == 'success'
}}
env:
BRANCH: ${{ needs.metadata.outputs.branch }}
CARD_DISPATCHED: ${{ steps.card_dispatch.outputs.dispatched }}
CHANNEL_LABEL: Prerelease
COMMIT: ${{ needs.metadata.outputs.commit }}
LANE_SUMMARY: "card dispatch=${{ steps.card_dispatch.outcome }} · tests dispatch=${{ steps.tests_dispatch.outcome }}"
ORIGIN_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# This job runs beside the build, so the packages are still coming.
# A probe would be worse than useless here: a job cannot ask the API
# whether its own run has finished.
PIPELINE_PROGRESS: running
RELEASE_CHANNEL: prerelease
RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
STAGE: dispatch
VERSION: ${{ needs.metadata.outputs.release_version }}
run: node --experimental-strip-types tools/release/src/notifications/prerelease-fallback-notice.ts
- name: Fail loudly when the fallback bot is itself unconfigured
# Both notification paths down at once is an incident of its own, and a
# red job is the only channel left to say so. Only reachable on this
# repository: the composer above is scoped to it, so a fork — which has
# neither bot — leaves `alert` empty and never goes red for this.
# `always()` because the whole point is to speak after something
# broke — without it GitHub's implicit success() skips this step in
# every run where an earlier step already failed. The outcome check
# is the other half: never act on a composer that did not finish.
if: >-
${{
always() && steps.notice.outcome == 'success' &&
steps.notice.outputs.alert == 'true' && env.FEISHU_WEBHOOK == ''
}}
run: |
echo "::error::the prerelease card was never dispatched and FEISHU_RELEASE_WEBHOOK is unset, so no fallback notice could be delivered"
exit 1
- name: Post the fallback notice
if: >-
${{
always() && steps.notice.outcome == 'success' &&
steps.notice.outputs.alert == 'true' && env.FEISHU_WEBHOOK != ''
}}
env:
NOTICE_BODY: ${{ steps.notice.outputs.body }}
NOTICE_TEMPLATE: ${{ steps.notice.outputs.template }}
NOTICE_TITLE: ${{ steps.notice.outputs.title }}
RUN_URL: ${{ steps.notice.outputs.run_url }}
run: node --experimental-strip-types tools/release/src/notifications/feishu-notice.ts
build_mac:
name: Build prerelease mac arm64
# Deliberately NOT gated on any test suite, and neither is `publish`. A
# prerelease exists so a person can install it and test it by hand;
# automated CI is a parallel opinion about the same commit, not a valve on
# delivery. Chaining the two meant a single flaky suite cost the team a
# whole day's package — the expensive failure mode, and the one this
# pipeline kept hitting. The suites still run on every prerelease, in
# release-prerelease-tests.yml, where they can take as long as they like
# without holding this workflow's concurrency group.
needs: metadata
runs-on: macos-14
env:
GH_TOKEN: ${{ github.token }}
RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
ref: ${{ needs.metadata.outputs.commit }}
# Shallow — see the metadata job's checkout for why.
fetch-depth: 1
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Compute mac_arm64 tools-pack cache key
id: mac_arm64_tools_pack_cache_key
env:
MAC_TOOLS_PACK_ORIGIN_KEY: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'apps/daemon/**', 'apps/web/**', 'apps/desktop/**', 'apps/packaged/**', 'packages/agui-adapter/**', 'packages/contracts/**', 'packages/plugin-runtime/**', 'packages/sidecar-proto/**', 'packages/sidecar/**', 'packages/platform/**', 'tools/pack/bin/**', 'tools/pack/package.json', 'tools/pack/resources/**', 'tools/pack/src/**', 'tools/pack/tsconfig.json', 'assets/community-pets/**', 'assets/frames/**', 'craft/**', 'design-systems/**', 'design-templates/**', 'plugins/_official/**', 'plugins/registry/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-prerelease.yml', '.github/scripts/release/cache/mac.sh') }}
run: |
set -euo pipefail
if [ -z "$MAC_TOOLS_PACK_ORIGIN_KEY" ]; then
echo "mac_arm64 tools-pack cache origin key is empty" >&2
exit 1
fi
prefix="tools-pack-mac-v1-prerelease-${RUNNER_OS}-arm64-"
{
echo "origin=$MAC_TOOLS_PACK_ORIGIN_KEY"
echo "prefix=$prefix"
echo "key=$prefix$MAC_TOOLS_PACK_ORIGIN_KEY"
} >> "$GITHUB_OUTPUT"
- name: Restore mac_arm64 tools-pack cache
id: mac_arm64_tools_pack_cache_restore
uses: actions/cache/restore@v5
continue-on-error: true
with:
path: ${{ runner.temp }}/tools-pack-cache
key: ${{ steps.mac_arm64_tools_pack_cache_key.outputs.key }}
restore-keys: |
${{ steps.mac_arm64_tools_pack_cache_key.outputs.prefix }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Inspect mac Electron framework symlinks
run: |
set -euo pipefail
electron_dist="$(node -e 'const path = require("node:path"); const { createRequire } = require("node:module"); const requireFromDesktop = createRequire(path.join(process.cwd(), "apps/desktop/package.json")); const electron = requireFromDesktop.resolve("electron"); process.stdout.write(path.join(path.dirname(electron), "dist"));')"
framework="$electron_dist/Electron.app/Contents/Frameworks/Electron Framework.framework"
missing_links=0
for link in \
"$framework/Electron Framework" \
"$framework/Helpers" \
"$framework/Libraries" \
"$framework/Resources" \
"$framework/Versions/Current"; do
if [ ! -L "$link" ]; then
echo "::warning::Expected Electron framework symlink, got non-symlink: $link"
missing_links=1
fi
done
if [ "$missing_links" -ne 0 ]; then
ls -la "$framework" >&2 || true
ls -la "$framework/Versions" >&2 || true
echo "Continuing into tools-pack because electron-builder is the source of truth for whether packaging actually works."
fi
- name: Prepare Apple signing certificate
if: ${{ inputs.mac_sign_mode != 'no' }}
env:
APPLE_SIGNING_CERTIFICATE_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_BASE64 }}
APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
cert_path="$RUNNER_TEMP/open-design-signing.p12"
if ! printf '%s' "$APPLE_SIGNING_CERTIFICATE_BASE64" | base64 --decode > "$cert_path" 2>/dev/null; then
printf '%s' "$APPLE_SIGNING_CERTIFICATE_BASE64" | base64 -D > "$cert_path"
fi
{
echo "CSC_LINK=$cert_path"
echo "CSC_KEY_PASSWORD=$APPLE_SIGNING_CERTIFICATE_PASSWORD"
} >> "$GITHUB_ENV"
- name: Build prerelease mac artifacts
id: mac_arm64_tools_pack_build
continue-on-error: true
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/release-build/mac_arm64"
rm -rf "$RUNNER_TEMP/tools-pack"
: > "$RUNNER_TEMP/release-build/mac_arm64/build.log"
build_args=(
exec tools-pack mac build
--dir "$RUNNER_TEMP/tools-pack"
--cache-dir "$RUNNER_TEMP/tools-pack-cache"
--namespace release-prerelease
--portable
--app-version "${{ needs.metadata.outputs.release_version }}"
--mac-compression normal
--to all
--json
)
# Signing depth is a dispatch input (default sign-only), mirroring
# release-beta.yml. Prerelease keeps --signed on every mode except an
# explicit 'no', because tools/release/src/metadata/prepare-stable.ts
# requires platforms.mac.signed == true (and platforms.macIntel.signed
# == true) in the prerelease metadata it promotes; an unsigned
# prerelease cannot become a stable release. Notarization is what the
# default drops: it is a round-trip to Apple's notary service that
# buys nothing for an internal channel, and metadata carries no
# notarized field for the stable gate to check.
case "${{ inputs.mac_sign_mode }}" in
no) ;;
sign-only) build_args+=(--signed) ;;
notarize) build_args+=(--signed --notarize) ;;
*) echo "unsupported mac_sign_mode: ${{ inputs.mac_sign_mode }}" >&2; exit 1 ;;
esac
if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$RUNNER_TEMP/release-build/mac_arm64/build.log" >&2))"; then
printf '%s\n' "$build_output" | tee "$RUNNER_TEMP/release-build/mac_arm64/build.json"
else
build_status=$?
printf '%s\n' "$build_output"
exit "$build_status"
fi
- name: Retry prerelease mac_arm64 without restored cache
if: ${{ steps.mac_arm64_tools_pack_build.outcome == 'failure' }}
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
rm -rf "$RUNNER_TEMP/tools-pack-cache"
mkdir -p "$RUNNER_TEMP/release-build/mac_arm64"
pnpm exec tools-pack mac cleanup --dir "$RUNNER_TEMP/tools-pack" --namespace release-prerelease --json
: > "$RUNNER_TEMP/release-build/mac_arm64/build.log"
build_args=(
exec tools-pack mac build
--dir "$RUNNER_TEMP/tools-pack"
--cache-dir "$RUNNER_TEMP/tools-pack-cache"
--namespace release-prerelease
--portable
--app-version "${{ needs.metadata.outputs.release_version }}"
--mac-compression normal
--to all
--json
)
# Signing depth follows mac_sign_mode — see the primary mac arm64 build.
case "${{ inputs.mac_sign_mode }}" in
no) ;;
sign-only) build_args+=(--signed) ;;
notarize) build_args+=(--signed --notarize) ;;
*) echo "unsupported mac_sign_mode: ${{ inputs.mac_sign_mode }}" >&2; exit 1 ;;
esac
if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$RUNNER_TEMP/release-build/mac_arm64/build.log" >&2))"; then
printf '%s\n' "$build_output" | tee "$RUNNER_TEMP/release-build/mac_arm64/build.json"
else
build_status=$?
printf '%s\n' "$build_output"
exit "$build_status"
fi
- name: Delete failed mac_arm64 tools-pack cache
if: ${{ steps.mac_arm64_tools_pack_build.outcome == 'failure' && steps.mac_arm64_tools_pack_cache_restore.outputs.cache-matched-key != '' }}
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
matched_key="${{ steps.mac_arm64_tools_pack_cache_restore.outputs.cache-matched-key }}"
cache_ids="$(gh cache list --key "$matched_key" --limit 100 --json id,key,ref --jq ".[] | select(.key == \"$matched_key\") | .id")"
count=0
while IFS= read -r cache_id; do
if [ -n "$cache_id" ]; then
gh cache delete "$cache_id"
count=$((count + 1))
fi
done <<< "$cache_ids"
echo "deletedFailedCacheKey=$matched_key count=$count"
- name: Verify prerelease mac_arm64 signature and notarization
if: ${{ inputs.mac_sign_mode != 'no' }}
run: |
set -euo pipefail
build_json="$RUNNER_TEMP/release-build/mac_arm64/build.json"
dmg_path="$(node -e 'const fs = require("node:fs"); const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8")).dmgPath; if (typeof value !== "string" || value.length === 0) process.exit(1); process.stdout.write(value);' "$build_json")"
if [ ! -f "$dmg_path" ]; then
echo "prerelease mac_arm64 DMG not found: $dmg_path" >&2
exit 1
fi
mount_point="$(mktemp -d "$RUNNER_TEMP/prerelease-mac-arm64.XXXXXX")"
cleanup() {
hdiutil detach "$mount_point" >/dev/null 2>&1 || true
rmdir "$mount_point" >/dev/null 2>&1 || true
}
trap cleanup EXIT
hdiutil attach "$dmg_path" -nobrowse -readonly -mountpoint "$mount_point"
candidate_app="$mount_point/Open Design Prerelease.app"
if [ ! -d "$candidate_app" ]; then
echo "prerelease app not found in DMG: $candidate_app" >&2
exit 1
fi
# The signature is checked on every signed build, because
# platforms.mac[Intel].signed == true is what the stable promotion
# gate reads. The stapled-ticket and Gatekeeper assessments only pass
# for a notarized build, so they follow mac_sign_mode rather than
# failing the job for doing exactly what it was asked to do.
codesign --verify --deep --strict "$candidate_app"
if [ "${{ inputs.mac_sign_mode }}" = "notarize" ]; then
xcrun stapler validate "$candidate_app"
spctl --assess --type execute --verbose=4 "$candidate_app"
else
echo "mac_sign_mode=${{ inputs.mac_sign_mode }}: signature verified; skipping stapler/spctl, which require a notarization ticket this build did not request."
fi
- name: Capture mac framework diagnostics
if: ${{ failure() }}
continue-on-error: true
run: |
set -euo pipefail
output="$RUNNER_TEMP/mac-framework-diagnostics.txt"
source_resolve_log="$RUNNER_TEMP/mac-framework-source-resolve.err"
source_framework="$(node -e 'const path = require("node:path"); const { createRequire } = require("node:module"); const requireFromDesktop = createRequire(path.join(process.cwd(), "apps/desktop/package.json")); const electron = requireFromDesktop.resolve("electron"); process.stdout.write(path.join(path.dirname(electron), "dist", "Electron.app", "Contents", "Frameworks", "Electron Framework.framework"));' 2>"$source_resolve_log" || true)"
built_framework="$RUNNER_TEMP/tools-pack/out/mac/namespaces/release-prerelease/builder/mac-arm64/Open Design Prerelease.app/Contents/Frameworks/Electron Framework.framework"
dump_framework() {
local label="$1"
local framework="$2"
echo "## $label"
echo "path=$framework"
if [ ! -e "$framework" ] && [ ! -L "$framework" ]; then
echo "missing"
return 0
fi
echo "### top-level"
ls -la "$framework" || true
echo "### symlinks"
find "$framework" -maxdepth 4 -type l -print0 | while IFS= read -r -d '' link; do
printf '%s -> %s\n' "$link" "$(readlink "$link")"
done || true
echo "### selected stat"
for path in \
"$framework" \
"$framework/Electron Framework" \
"$framework/Versions" \
"$framework/Versions/Current" \
"$framework/Versions/Current/Electron Framework" \
"$framework/Versions/A" \
"$framework/Versions/A/Electron Framework" \
"$framework/Resources" \
"$framework/Versions/A/Resources/Info.plist"; do
if [ -e "$path" ] || [ -L "$path" ]; then
stat -f '%Sp %HT %N' "$path" || true
else
echo "missing: $path"
fi
done
echo "### plist"
plutil -p "$framework/Versions/A/Resources/Info.plist" 2>&1 || true
echo "### codesign display"
codesign --display --verbose=4 "$framework/Electron Framework" 2>&1 || true
codesign --display --verbose=4 "$framework/Versions/Current/Electron Framework" 2>&1 || true
codesign --display --verbose=4 "$framework/Versions/A/Electron Framework" 2>&1 || true
codesign --display --verbose=4 "$framework" 2>&1 || true
}
{
date -u
if [ -n "$source_framework" ]; then
dump_framework "source Electron Framework" "$source_framework"
else
echo "## source Electron Framework"
echo "resolve failed"
cat "$source_resolve_log" || true
fi
dump_framework "built Electron Framework" "$built_framework"
} > "$output"
cat "$output"
- name: Upload mac build diagnostics
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: open-design-prerelease-mac-build-diagnostics
path: |
${{ runner.temp }}/release-build/mac_arm64/build.log
${{ runner.temp }}/release-build/mac_arm64/build.json
${{ runner.temp }}/mac-framework-diagnostics.txt
if-no-files-found: warn
- name: Write mac_arm64 release report
if: ${{ always() }}
env:
BUILD_JSON_PATH: ${{ runner.temp }}/release-build/mac_arm64/build.json
BUILD_LOG_PATH: ${{ runner.temp }}/release-build/mac_arm64/build.log
RELEASE_BUILD_TARGET: all
RELEASE_CHANNEL: prerelease
RELEASE_NAMESPACE: release-prerelease
RELEASE_REPORT_DIR: ${{ runner.temp }}/release-report/mac_arm64
RELEASE_REPORT_JSON_PATH: ${{ runner.temp }}/release-report/mac_arm64/report.json
RELEASE_REPORT_SUMMARY_PATH: ${{ runner.temp }}/release-report/mac_arm64/summary.md
# The build jobs no longer smoke; the packaged smoke lives in
# release-prerelease-smoke.yml and writes its own report there.
RELEASE_SMOKE_MODE: skip
RELEASE_TARGET: mac_arm64
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
REPORT_TITLE: mac_arm64 prerelease build
run: pnpm exec tools-release write-report
- name: Publish mac_arm64 report summary
if: ${{ always() }}
run: cat "${{ runner.temp }}/release-report/mac_arm64/summary.md" >> "$GITHUB_STEP_SUMMARY"
- name: Prune mac_arm64 tools-pack cache
continue-on-error: true
run: bash .github/scripts/release/cache/mac.sh
- name: Save mac_arm64 tools-pack cache
if: ${{ success() && (steps.mac_arm64_tools_pack_cache_restore.outputs.cache-hit != 'true' || steps.mac_arm64_tools_pack_build.outcome == 'failure') }}
uses: actions/cache/save@v5
continue-on-error: true
with:
path: ${{ runner.temp }}/tools-pack-cache
key: ${{ steps.mac_arm64_tools_pack_cache_key.outputs.key }}
- name: Retain recent mac_arm64 tools-pack caches
if: ${{ success() }}
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
prefix="${{ steps.mac_arm64_tools_pack_cache_key.outputs.prefix }}"
keep=1
cache_ids="$(gh cache list --key "$prefix" --sort created_at --order desc --limit 100 --json id,key,createdAt --jq ".[$keep:] | .[].id")"
count=0
while IFS= read -r cache_id; do
if [ -n "$cache_id" ]; then
gh cache delete "$cache_id"
count=$((count + 1))
fi
done <<< "$cache_ids"
echo "actionsCachePrefix=$prefix deleted=$count"
- name: Prepare mac prerelease assets
env:
RELEASE_ARTIFACT_MODE: all
RELEASE_ASSET_SUFFIX: ""
RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets/mac_arm64
RELEASE_CHANNEL: prerelease
RELEASE_NAMESPACE: release-prerelease
RELEASE_NOTES: Open Design Prerelease ${{ needs.metadata.outputs.release_version }}
RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_TARGET: mac_arm64
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
run: bash tools/release/scripts/prepare-platform-assets.sh
- name: Publish mac prerelease platform
env:
RELEASE_ARTIFACT_MODE: all
RELEASE_ASSET_SUFFIX: ""
RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets/mac_arm64
RELEASE_CHANNEL: prerelease
RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests
RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-platform-outputs/mac_arm64.json
RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_REPORT_DIR: ${{ runner.temp }}/release-report/mac_arm64
RELEASE_REPORT_ZIP_PATH: ${{ runner.temp }}/release-report/mac_arm64-report.zip
# platforms.mac[Intel].signed in the published metadata comes from
# this value via the platform manifest, and the stable promotion gate
# reads it. It must describe what was actually done, not a constant.
RELEASE_SIGNED: ${{ inputs.mac_sign_mode != 'no' && 'true' || 'false' }}
RELEASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
RELEASE_STORAGE_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
RELEASE_STORAGE_ENDPOINT: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
RELEASE_STORAGE_REGION: auto
RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
RELEASE_TARGET: mac_arm64
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
run: pnpm exec tools-release publish-platform
- name: Upload mac prerelease publish manifest
uses: actions/upload-artifact@v7
with:
name: open-design-prerelease-mac-arm64-publish-manifest
path: ${{ runner.temp }}/release-platform-manifests/mac_arm64.json
if-no-files-found: error
retention-days: 1
# The packaged smoke used to run here, after the publish manifest. It now
# runs in release-prerelease-smoke.yml against the DMG re-downloaded from
# R2 — closer to what a user installs, and off this workflow's
# concurrency group entirely.
build_mac_intel:
name: Build prerelease mac intel x64
# Tests never gate delivery on this channel — see build_mac.
needs: metadata
# On by default, and switchable. Intel is this pipeline's critical path: it
# runs on the slower macos-15-intel fleet and takes ~33 min against ~21 min
# for every other platform. It still builds on every run, because the stable
# promotion gate reads platforms.macIntel out of the prerelease metadata and
# an Intel-less prerelease simply cannot be promoted — see the
# enable_mac_x64 input description and AGENTS.md "Release channel model".
if: ${{ inputs.enable_mac_x64 }}
runs-on: macos-15-intel
env:
GH_TOKEN: ${{ github.token }}
RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
ref: ${{ needs.metadata.outputs.commit }}
# Shallow — see the metadata job's checkout for why.
fetch-depth: 1
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Compute mac_x64 tools-pack cache key
id: mac_x64_tools_pack_cache_key
env:
MAC_TOOLS_PACK_ORIGIN_KEY: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'apps/daemon/**', 'apps/web/**', 'apps/desktop/**', 'apps/packaged/**', 'packages/agui-adapter/**', 'packages/contracts/**', 'packages/plugin-runtime/**', 'packages/sidecar-proto/**', 'packages/sidecar/**', 'packages/platform/**', 'tools/pack/bin/**', 'tools/pack/package.json', 'tools/pack/resources/**', 'tools/pack/src/**', 'tools/pack/tsconfig.json', 'assets/community-pets/**', 'assets/frames/**', 'craft/**', 'design-systems/**', 'design-templates/**', 'plugins/_official/**', 'plugins/registry/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-prerelease.yml', '.github/scripts/release/cache/mac.sh') }}
run: |
set -euo pipefail
if [ -z "$MAC_TOOLS_PACK_ORIGIN_KEY" ]; then
echo "mac_x64 tools-pack cache origin key is empty" >&2
exit 1
fi
prefix="tools-pack-mac-v1-prerelease-${RUNNER_OS}-x64-"
{
echo "origin=$MAC_TOOLS_PACK_ORIGIN_KEY"
echo "prefix=$prefix"
echo "key=$prefix$MAC_TOOLS_PACK_ORIGIN_KEY"
} >> "$GITHUB_OUTPUT"
- name: Restore mac_x64 tools-pack cache
id: mac_x64_tools_pack_cache_restore
uses: actions/cache/restore@v5
continue-on-error: true
with:
path: ${{ runner.temp }}/tools-pack-cache
key: ${{ steps.mac_x64_tools_pack_cache_key.outputs.key }}
restore-keys: |
${{ steps.mac_x64_tools_pack_cache_key.outputs.prefix }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Prepare Apple signing certificate
if: ${{ inputs.mac_sign_mode != 'no' }}
env:
APPLE_SIGNING_CERTIFICATE_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_BASE64 }}
APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
cert_path="$RUNNER_TEMP/open-design-signing.p12"
if ! printf '%s' "$APPLE_SIGNING_CERTIFICATE_BASE64" | base64 --decode > "$cert_path" 2>/dev/null; then
printf '%s' "$APPLE_SIGNING_CERTIFICATE_BASE64" | base64 -D > "$cert_path"
fi
{
echo "CSC_LINK=$cert_path"
echo "CSC_KEY_PASSWORD=$APPLE_SIGNING_CERTIFICATE_PASSWORD"
} >> "$GITHUB_ENV"
- name: Build prerelease mac intel artifacts
id: mac_x64_tools_pack_build
continue-on-error: true
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/release-build/mac_x64"
rm -rf "$RUNNER_TEMP/tools-pack"
: > "$RUNNER_TEMP/release-build/mac_x64/build.log"
build_args=(
exec tools-pack mac build
--dir "$RUNNER_TEMP/tools-pack"
--cache-dir "$RUNNER_TEMP/tools-pack-cache"
--namespace release-prerelease-intel
--portable
--app-version "${{ needs.metadata.outputs.release_version }}"
--mac-compression normal
--to all
--json
)
# Signing depth follows mac_sign_mode — see the primary mac arm64 build.
case "${{ inputs.mac_sign_mode }}" in
no) ;;
sign-only) build_args+=(--signed) ;;
notarize) build_args+=(--signed --notarize) ;;
*) echo "unsupported mac_sign_mode: ${{ inputs.mac_sign_mode }}" >&2; exit 1 ;;
esac
if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$RUNNER_TEMP/release-build/mac_x64/build.log" >&2))"; then
printf '%s\n' "$build_output" | tee "$RUNNER_TEMP/release-build/mac_x64/build.json"
else
build_status=$?
printf '%s\n' "$build_output"
exit "$build_status"
fi
- name: Retry prerelease mac_x64 without restored cache
if: ${{ steps.mac_x64_tools_pack_build.outcome == 'failure' }}
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
rm -rf "$RUNNER_TEMP/tools-pack-cache"
mkdir -p "$RUNNER_TEMP/release-build/mac_x64"
pnpm exec tools-pack mac cleanup --dir "$RUNNER_TEMP/tools-pack" --namespace release-prerelease-intel --json
: > "$RUNNER_TEMP/release-build/mac_x64/build.log"
build_args=(
exec tools-pack mac build
--dir "$RUNNER_TEMP/tools-pack"
--cache-dir "$RUNNER_TEMP/tools-pack-cache"
--namespace release-prerelease-intel
--portable
--app-version "${{ needs.metadata.outputs.release_version }}"
--mac-compression normal
--to all
--json
)
# Signing depth follows mac_sign_mode — see the primary mac arm64 build.
case "${{ inputs.mac_sign_mode }}" in
no) ;;
sign-only) build_args+=(--signed) ;;
notarize) build_args+=(--signed --notarize) ;;
*) echo "unsupported mac_sign_mode: ${{ inputs.mac_sign_mode }}" >&2; exit 1 ;;
esac
if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$RUNNER_TEMP/release-build/mac_x64/build.log" >&2))"; then
printf '%s\n' "$build_output" | tee "$RUNNER_TEMP/release-build/mac_x64/build.json"
else
build_status=$?
printf '%s\n' "$build_output"
exit "$build_status"
fi
- name: Delete failed mac_x64 tools-pack cache
if: ${{ steps.mac_x64_tools_pack_build.outcome == 'failure' && steps.mac_x64_tools_pack_cache_restore.outputs.cache-matched-key != '' }}
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
matched_key="${{ steps.mac_x64_tools_pack_cache_restore.outputs.cache-matched-key }}"
cache_ids="$(gh cache list --key "$matched_key" --limit 100 --json id,key,ref --jq ".[] | select(.key == \"$matched_key\") | .id")"
count=0
while IFS= read -r cache_id; do
if [ -n "$cache_id" ]; then
gh cache delete "$cache_id"
count=$((count + 1))
fi
done <<< "$cache_ids"
echo "deletedFailedCacheKey=$matched_key count=$count"
- name: Verify prerelease mac_x64 signature and notarization
if: ${{ inputs.mac_sign_mode != 'no' }}
run: |
set -euo pipefail
build_json="$RUNNER_TEMP/release-build/mac_x64/build.json"
dmg_path="$(node -e 'const fs = require("node:fs"); const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8")).dmgPath; if (typeof value !== "string" || value.length === 0) process.exit(1); process.stdout.write(value);' "$build_json")"
if [ ! -f "$dmg_path" ]; then
echo "prerelease mac_x64 DMG not found: $dmg_path" >&2
exit 1
fi
mount_point="$(mktemp -d "$RUNNER_TEMP/prerelease-mac-x64.XXXXXX")"
cleanup() {
hdiutil detach "$mount_point" >/dev/null 2>&1 || true
rmdir "$mount_point" >/dev/null 2>&1 || true
}
trap cleanup EXIT
hdiutil attach "$dmg_path" -nobrowse -readonly -mountpoint "$mount_point"
candidate_app="$mount_point/Open Design Prerelease.app"
if [ ! -d "$candidate_app" ]; then
echo "prerelease app not found in DMG: $candidate_app" >&2
exit 1
fi
# The signature is checked on every signed build, because
# platforms.mac[Intel].signed == true is what the stable promotion
# gate reads. The stapled-ticket and Gatekeeper assessments only pass
# for a notarized build, so they follow mac_sign_mode rather than
# failing the job for doing exactly what it was asked to do.
codesign --verify --deep --strict "$candidate_app"
if [ "${{ inputs.mac_sign_mode }}" = "notarize" ]; then
xcrun stapler validate "$candidate_app"
spctl --assess --type execute --verbose=4 "$candidate_app"
else
echo "mac_sign_mode=${{ inputs.mac_sign_mode }}: signature verified; skipping stapler/spctl, which require a notarization ticket this build did not request."
fi
- name: Write mac_x64 release report
if: ${{ always() }}
env:
BUILD_JSON_PATH: ${{ runner.temp }}/release-build/mac_x64/build.json
BUILD_LOG_PATH: ${{ runner.temp }}/release-build/mac_x64/build.log
RELEASE_BUILD_TARGET: all
RELEASE_CHANNEL: prerelease
RELEASE_NAMESPACE: release-prerelease-intel
RELEASE_REPORT_DIR: ${{ runner.temp }}/release-report/mac_x64
RELEASE_REPORT_JSON_PATH: ${{ runner.temp }}/release-report/mac_x64/report.json
RELEASE_REPORT_SUMMARY_PATH: ${{ runner.temp }}/release-report/mac_x64/summary.md
# The build jobs no longer smoke; the packaged smoke lives in
# release-prerelease-smoke.yml and writes its own report there.
RELEASE_SMOKE_MODE: skip
RELEASE_TARGET: mac_x64
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
REPORT_TITLE: mac_x64 prerelease build
run: pnpm exec tools-release write-report
- name: Publish mac_x64 report summary
if: ${{ always() }}
run: cat "${{ runner.temp }}/release-report/mac_x64/summary.md" >> "$GITHUB_STEP_SUMMARY"
- name: Prune mac_x64 tools-pack cache
continue-on-error: true
run: bash .github/scripts/release/cache/mac.sh
- name: Save mac_x64 tools-pack cache
if: ${{ success() && (steps.mac_x64_tools_pack_cache_restore.outputs.cache-hit != 'true' || steps.mac_x64_tools_pack_build.outcome == 'failure') }}
uses: actions/cache/save@v5
continue-on-error: true
with:
path: ${{ runner.temp }}/tools-pack-cache
key: ${{ steps.mac_x64_tools_pack_cache_key.outputs.key }}
- name: Retain recent mac_x64 tools-pack caches
if: ${{ success() }}
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
prefix="${{ steps.mac_x64_tools_pack_cache_key.outputs.prefix }}"
keep=1
cache_ids="$(gh cache list --key "$prefix" --sort created_at --order desc --limit 100 --json id,key,createdAt --jq ".[$keep:] | .[].id")"
count=0
while IFS= read -r cache_id; do
if [ -n "$cache_id" ]; then
gh cache delete "$cache_id"
count=$((count + 1))
fi
done <<< "$cache_ids"
echo "actionsCachePrefix=$prefix deleted=$count"
- name: Prepare mac intel prerelease assets
env:
RELEASE_ARTIFACT_MODE: all
RELEASE_ASSET_SUFFIX: ""
RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets/mac_x64
RELEASE_CHANNEL: prerelease
RELEASE_NAMESPACE: release-prerelease-intel
RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_TARGET: mac_x64
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
run: bash tools/release/scripts/prepare-platform-assets.sh
- name: Publish mac intel prerelease platform
env:
RELEASE_ARTIFACT_MODE: all
RELEASE_ASSET_SUFFIX: ""
RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets/mac_x64
RELEASE_CHANNEL: prerelease
RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests
RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-platform-outputs/mac_x64.json
RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_REPORT_DIR: ${{ runner.temp }}/release-report/mac_x64
RELEASE_REPORT_ZIP_PATH: ${{ runner.temp }}/release-report/mac_x64-report.zip
# platforms.mac[Intel].signed in the published metadata comes from
# this value via the platform manifest, and the stable promotion gate
# reads it. It must describe what was actually done, not a constant.
RELEASE_SIGNED: ${{ inputs.mac_sign_mode != 'no' && 'true' || 'false' }}
RELEASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
RELEASE_STORAGE_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
RELEASE_STORAGE_ENDPOINT: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
RELEASE_STORAGE_REGION: auto
RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
RELEASE_TARGET: mac_x64
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
run: pnpm exec tools-release publish-platform
- name: Upload mac intel prerelease publish manifest
uses: actions/upload-artifact@v7
with:
name: open-design-prerelease-mac-x64-publish-manifest
path: ${{ runner.temp }}/release-platform-manifests/mac_x64.json
if-no-files-found: error
retention-days: 1
# Packaged smoke moved to release-prerelease-smoke.yml — see build_mac.
build_win:
name: Build prerelease win x64
# Tests never gate delivery on this channel — see build_mac.
needs: metadata
runs-on: windows-latest
env:
GH_TOKEN: ${{ github.token }}
RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
ref: ${{ needs.metadata.outputs.commit }}
# Shallow — see the metadata job's checkout for why.
fetch-depth: 1
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
# No `cache: pnpm`. It caches the pnpm STORE, and on this repository that
# store never paid for itself on Windows. Measured across four consecutive
# prerelease runs: on a miss (3 of 4) it cost 100s / 66s / 43s in the
# "Post Setup Node.js" save with no install speedup, and on the one hit it
# traded a 32s restore for a 36s faster install — a wash. Worse, it
# competes for the repository's 10 GB Actions cache budget against the
# per-lane tools-pack caches, which are worth ~11 min of build time each.
# This is now the only release build job without it, which is exactly the
# shape every other build job (mac arm64, mac Intel, linux) already had.
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Compute Windows tools-pack cache key
id: win_tools_pack_cache_key
shell: pwsh
env:
WIN_TOOLS_PACK_ORIGIN_KEY: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'apps/daemon/**', 'apps/web/**', 'apps/desktop/**', 'apps/packaged/**', 'packages/agui-adapter/**', 'packages/contracts/**', 'packages/plugin-runtime/**', 'packages/sidecar-proto/**', 'packages/sidecar/**', 'packages/platform/**', 'tools/pack/bin/**', 'tools/pack/package.json', 'tools/pack/resources/**', 'tools/pack/src/**', 'tools/pack/tsconfig.json', 'assets/community-pets/**', 'assets/frames/**', 'craft/**', 'design-systems/**', 'design-templates/**', 'plugins/_official/**', 'plugins/registry/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-prerelease.yml', '.github/scripts/release/cache/win.ps1', '.github/scripts/release/cache/windows.ts', '.github/actions/defer-pack-cache/action.yml') }}
run: |
if ([string]::IsNullOrWhiteSpace($env:WIN_TOOLS_PACK_ORIGIN_KEY)) {
throw "Windows tools-pack cache origin key is empty"
}
$prefix = "tools-pack-win-v2-slim-prerelease-$env:RUNNER_OS-"
"origin=$env:WIN_TOOLS_PACK_ORIGIN_KEY" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"prefix=$prefix" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"key=$prefix$env:WIN_TOOLS_PACK_ORIGIN_KEY" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
- name: Restore Windows tools-pack cache
id: win_tools_pack_cache_restore
uses: actions/cache/restore@v5
continue-on-error: true
with:
path: ${{ runner.temp }}/tools-pack-cache
key: ${{ steps.win_tools_pack_cache_key.outputs.key }}
restore-keys: |
${{ steps.win_tools_pack_cache_key.outputs.prefix }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup NSIS
shell: pwsh
run: |
# windows-latest no longer guarantees a preinstalled NSIS, and the
# Chocolatey community feed flakes: prerelease.4 hit a silent
# "Unable to find package 'nsis'" fetch failure that left this step
# green (pwsh -command does not propagate choco's exit code) and let
# the build die on makensis half an hour later. Retry the install
# and hard-fail HERE, with a clear message, if makensis still cannot
# be found — a red Setup NSIS step is diagnosable; a mid-build
# makensis error is not.
function Test-Makensis {
[bool]((Get-Command makensis.exe -ErrorAction SilentlyContinue) -or (Test-Path "C:\Program Files (x86)\NSIS\makensis.exe"))
}
if (Test-Makensis) { Write-Host "makensis already present"; exit 0 }
foreach ($attempt in 1..4) {
Write-Host "choco install nsis (attempt $attempt)"
choco install nsis -y --no-progress
if (Test-Makensis) { break }
Start-Sleep -Seconds (15 * $attempt)
}
if (-not (Test-Makensis)) {
Write-Error "NSIS install failed after 4 attempts: makensis absent from PATH and C:\Program Files (x86)\NSIS"
exit 1
}
Write-Host "NSIS ready"
- name: Build prerelease windows artifacts
id: win_tools_pack_build
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$toolsPackDir = "${{ runner.temp }}/tools-pack"
$cacheDir = "${{ runner.temp }}/tools-pack-cache"
$buildJsonPath = Join-Path $env:RUNNER_TEMP "release-build\win_x64\build.json"
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $buildJsonPath) | Out-Null
pnpm.cmd exec tools-pack win cleanup --dir $toolsPackDir --namespace release-prerelease-win --json
$buildArgs = @(
"exec", "tools-pack", "win", "build",
"--dir", $toolsPackDir,
"--cache-dir", $cacheDir,
"--namespace", "release-prerelease-win",
"--portable",
"--app-version", "${{ needs.metadata.outputs.release_version }}",
"--to", "all",
"--json"
)
"cache_failed=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
try {
$buildOutput = pnpm @buildArgs
if ($LASTEXITCODE -ne 0) {
throw "Windows tools-pack cached build exited with code $LASTEXITCODE"
}
} catch {
Write-Warning "Windows tools-pack cached build failed; removing restored cache and retrying without cache."
"cache_failed=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $cacheDir
pnpm.cmd exec tools-pack win cleanup --dir $toolsPackDir --namespace release-prerelease-win --json
$buildOutput = pnpm exec tools-pack win build `
--dir $toolsPackDir `
--cache-dir $cacheDir `
--namespace release-prerelease-win `
--portable `
--app-version "${{ needs.metadata.outputs.release_version }}" `
--to all `
--json
if ($LASTEXITCODE -ne 0) {
throw "Windows tools-pack uncached fallback build exited with code $LASTEXITCODE"
}
}
$buildOutput | Set-Content -Path $buildJsonPath
$build = $buildOutput | ConvertFrom-Json
pnpm.cmd exec tools-pack win validate-payload --namespace release-prerelease-win --payload-path $build.payloadPath --expected-version "${{ needs.metadata.outputs.release_version }}" --json
$buildOutput
- name: Delete failed Windows tools-pack cache
if: ${{ steps.win_tools_pack_build.outputs.cache_failed == 'true' && steps.win_tools_pack_cache_restore.outputs.cache-matched-key != '' }}
shell: pwsh
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
$matchedKey = "${{ steps.win_tools_pack_cache_restore.outputs.cache-matched-key }}"
$caches = @(gh cache list --key $matchedKey --limit 100 --json id,key,ref | ConvertFrom-Json | Where-Object { $_.key -eq $matchedKey })
foreach ($cache in $caches) {
gh cache delete $cache.id
}
"deletedFailedCacheKey=$matchedKey count=$($caches.Count)"
- name: Write win_x64 release report
if: ${{ always() }}
env:
BUILD_JSON_PATH: ${{ runner.temp }}\release-build\win_x64\build.json
RELEASE_BUILD_TARGET: all
RELEASE_CHANNEL: prerelease
RELEASE_NAMESPACE: release-prerelease-win
RELEASE_REPORT_DIR: ${{ runner.temp }}\release-report\win_x64
RELEASE_REPORT_JSON_PATH: ${{ runner.temp }}\release-report\win_x64\report.json
RELEASE_REPORT_SUMMARY_PATH: ${{ runner.temp }}\release-report\win_x64\summary.md
# See the mac lanes: packaged smoke moved out of this job.
RELEASE_SMOKE_MODE: skip
RELEASE_TARGET: win_x64
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
REPORT_TITLE: win_x64 prerelease build
run: pnpm exec tools-release write-report
- name: Publish win_x64 report summary
if: ${{ always() }}
shell: pwsh
run: Get-Content "${{ runner.temp }}\release-report\win_x64\summary.md" | Add-Content $env:GITHUB_STEP_SUMMARY
- name: Prepare windows prerelease assets
shell: pwsh
run: |
tools\release\scripts\prepare-platform-assets.ps1 `
-ReleaseTarget win_x64 `
-ReleaseAssetsDir "${{ runner.temp }}\release-assets\win_x64" `
-BuildJsonPath "${{ runner.temp }}\release-build\win_x64\build.json" `
-ReleaseNamespace release-prerelease-win `
-ReleaseVersion "${{ needs.metadata.outputs.release_version }}" `
-ReleaseAssetSuffix "" `
-ReleaseChannel prerelease `
-ReleasePublicOrigin "${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}" `
-ReleaseNotes "Open Design Prerelease ${{ needs.metadata.outputs.release_version }}" `
-IncludeZip $true
- name: Publish windows prerelease platform
env:
RELEASE_ASSET_SUFFIX: ""
RELEASE_ASSETS_DIR: ${{ runner.temp }}\release-assets\win_x64
RELEASE_CHANNEL: prerelease
RELEASE_MANIFEST_DIR: ${{ runner.temp }}\release-platform-manifests
RELEASE_OUTPUTS_PATH: ${{ runner.temp }}\release-platform-outputs\win_x64.json
RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_REPORT_DIR: ${{ runner.temp }}\release-report\win_x64
RELEASE_SIGNED: "false"
RELEASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
RELEASE_STORAGE_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
RELEASE_STORAGE_ENDPOINT: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
RELEASE_STORAGE_REGION: auto
RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
RELEASE_TARGET: win_x64
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
WIN_INCLUDE_ZIP: "true"
run: pnpm exec tools-release publish-platform
- name: Upload windows prerelease publish manifest
uses: actions/upload-artifact@v7
with:
name: open-design-prerelease-win-x64-publish-manifest
path: ${{ runner.temp }}\release-platform-manifests\win_x64.json
if-no-files-found: error
retention-days: 1
# The Windows update fixture and the packaged Windows smoke both moved
# to release-prerelease-smoke.yml, which rebuilds the fixture there when
# `full` coverage is requested and installs the setup.exe re-downloaded
# from R2 — see build_mac.
# Cache persistence is optional and runs outside this release workflow.
- name: Defer Windows tools-pack cache save
if: ${{ success() && (steps.win_tools_pack_cache_restore.outputs.cache-hit != 'true' || steps.win_tools_pack_build.outputs.cache_failed == 'true') }}
continue-on-error: true
uses: ./.github/actions/defer-pack-cache
with:
key: ${{ steps.win_tools_pack_cache_key.outputs.key }}
prefix: ${{ steps.win_tools_pack_cache_key.outputs.prefix }}
build_linux:
name: Build prerelease linux x64
# Tests never gate delivery on this channel — see build_mac.
needs: metadata
if: ${{ vars.ENABLE_STABLE_LINUX == 'true' }}
runs-on: ubuntu-latest
outputs:
smoke_result: ${{ steps.linux_smoke.outcome }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
ref: ${{ needs.metadata.outputs.commit }}
# Shallow — see the metadata job's checkout for why.
fetch-depth: 1
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build prerelease linux artifacts
run: |
set -euo pipefail
report_dir="$RUNNER_TEMP/release-report/linux_x64"
mkdir -p "$report_dir"
: > "$report_dir/tools-pack.log"
build_args=(
exec tools-pack linux build
--dir "$RUNNER_TEMP/tools-pack"
--namespace release-prerelease-linux
--portable
--app-version "${{ needs.metadata.outputs.release_version }}"
--to appimage
--containerized
--json
)
if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$report_dir/tools-pack.log" >&2))"; then
printf '%s\n' "$build_output" | tee "$report_dir/tools-pack.json"
node -e 'const fs = require("node:fs"); JSON.parse(fs.readFileSync(process.argv[1], "utf8"));' "$report_dir/tools-pack.json"
else
build_status=$?
printf '%s\n' "$build_output" | tee "$report_dir/tools-pack.json"
exit "$build_status"
fi
- name: Smoke prerelease linux AppImage runtime
id: linux_smoke
continue-on-error: true
working-directory: e2e
env:
OD_PACKAGED_E2E_LINUX_APPIMAGE: "1"
OD_PACKAGED_E2E_NAMESPACE: release-prerelease-linux
OD_PACKAGED_E2E_SCREENSHOT_PATH: ${{ runner.temp }}/release-report/linux_x64/screenshots/open-design-linux-smoke.png
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
run: |
set -euo pipefail
report_dir="$RUNNER_TEMP/release-report/linux_x64"
mkdir -p "$report_dir/screenshots"
cat > "$report_dir/manifest.json" <<EOF
{
"channel": "prerelease",
"platform": "linux",
"releaseVersion": "${{ needs.metadata.outputs.release_version }}",
"spec": "specs/linux.spec.ts",
"namespace": "release-prerelease-linux",
"screenshot": "screenshots/open-design-linux-smoke.png",
"githubRunId": "$GITHUB_RUN_ID",
"githubRunAttempt": "$GITHUB_RUN_ATTEMPT",
"commit": "${{ needs.metadata.outputs.commit }}"
}
EOF
sudo apt-get update 2>&1 | tee "$report_dir/apt-get-update.log"
sudo apt-get install -y xvfb 2>&1 | tee "$report_dir/apt-get-install-xvfb.log"
xvfb-run -a pnpm test specs/linux.spec.ts 2>&1 | tee "$report_dir/vitest.log"
- name: Upload linux e2e spec report
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: open-design-prerelease-linux-e2e-report
path: ${{ runner.temp }}/release-report/linux_x64
if-no-files-found: warn
- name: Prepare linux prerelease assets
env:
RELEASE_ASSET_SUFFIX: ""
RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets/linux_x64
RELEASE_CHANNEL: prerelease
RELEASE_NAMESPACE: release-prerelease-linux
RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_TARGET: linux_x64
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
run: bash tools/release/scripts/prepare-platform-assets.sh
- name: Publish linux prerelease platform
env:
RELEASE_ASSET_SUFFIX: ""
RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets/linux_x64
RELEASE_CHANNEL: prerelease
RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests
RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-platform-outputs/linux_x64.json
RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_SIGNED: "false"
RELEASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
RELEASE_STORAGE_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
RELEASE_STORAGE_ENDPOINT: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
RELEASE_STORAGE_REGION: auto
RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
RELEASE_TARGET: linux_x64
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
run: pnpm exec tools-release publish-platform
- name: Upload linux prerelease publish manifest
uses: actions/upload-artifact@v7
with:
name: open-design-prerelease-linux-x64-publish-manifest
path: ${{ runner.temp }}/release-platform-manifests/linux_x64.json
if-no-files-found: error
retention-days: 1
publish:
name: Publish prerelease release
# No test suite appears in `needs` or `if`, and none may be added.
# prerelease is the internal channel: its job is to put a package in a
# colleague's hands so a human can test it, and machine CI is a second,
# parallel signal about the same commit — not a valve on the first one. A
# flaky suite used to mean nobody got a build that day, which is a worse
# outcome than shipping an internal package alongside a red test. The
# suites now live in release-prerelease-tests.yml and the packaged smoke in
# release-prerelease-smoke.yml; the progressive Feishu card is what carries
# their verdict to a human. Stable is unaffected: its promotion gate reads
# the prerelease metadata through tools/release/src/metadata/prepare-stable.ts
# and runs its own checks.
needs:
- metadata
- build_mac
- build_mac_intel
- build_win
- build_linux
if: >-
${{
always() &&
!cancelled() &&
needs.metadata.result == 'success' &&
needs.build_mac.result == 'success' &&
(needs.build_mac_intel.result == 'success' || needs.build_mac_intel.result == 'skipped') &&
needs.build_win.result == 'success' &&
(needs.build_linux.result == 'success' || needs.build_linux.result == 'skipped')
}}
runs-on: ubuntu-latest
outputs:
version_metadata_url: ${{ steps.outputs.outputs.version_metadata_url }}
mac_arm64_url: ${{ steps.outputs.outputs.mac_arm64_dmg_url }}
mac_intel_url: ${{ steps.outputs.outputs.mac_x64_dmg_url }}
win_url: ${{ steps.outputs.outputs.win_x64_installer_url }}
linux_url: ${{ steps.outputs.outputs.linux_x64_appImage_url }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }}
GITHUB_SHA: ${{ needs.metadata.outputs.commit }}
BASE_VERSION: ${{ needs.metadata.outputs.base_version }}
BRANCH_NAME: ${{ needs.metadata.outputs.branch }}
ENABLE_LINUX: ${{ needs.build_linux.result == 'success' }}
ENABLE_MAC: "true"
ENABLE_WIN: "true"
GITHUB_RELEASE_ENABLED: "false"
RELEASE_CHANNEL: prerelease
RELEASE_NAME: ${{ needs.metadata.outputs.release_name }}
RELEASE_SIGNED: ${{ inputs.mac_sign_mode != 'no' && 'true' || 'false' }}
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
REPORT_MODE: zip
STATE_SOURCE: ${{ needs.metadata.outputs.state_source }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
ref: ${{ needs.metadata.outputs.commit }}
# Shallow — see the metadata job's checkout for why.
fetch-depth: 1
# The artifact downloads below run on the runner's own Node, and the pnpm
# toolchain this job actually uses is set up further down, right before
# `pnpm install`. The Setup Node.js that used to sit here was a second,
# redundant copy of that one — it paid for its own post-job step and
# bought nothing.
- name: Download mac prerelease publish manifest
uses: actions/download-artifact@v8
with:
name: open-design-prerelease-mac-arm64-publish-manifest
path: ${{ runner.temp }}/release-platform-manifests
- name: Download mac intel prerelease publish manifest
if: ${{ needs.build_mac_intel.result == 'success' }}
uses: actions/download-artifact@v8
with:
name: open-design-prerelease-mac-x64-publish-manifest
path: ${{ runner.temp }}/release-platform-manifests
- name: Download windows prerelease publish manifest
uses: actions/download-artifact@v8
with:
name: open-design-prerelease-win-x64-publish-manifest
path: ${{ runner.temp }}/release-platform-manifests
- name: Download linux prerelease publish manifest
if: ${{ needs.build_linux.result == 'success' }}
uses: actions/download-artifact@v8
with:
name: open-design-prerelease-linux-x64-publish-manifest
path: ${{ runner.temp }}/release-platform-manifests
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Prepare and publish prerelease release notes
env:
RELEASE_CHANNEL: prerelease
RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json
RELEASE_NOTE_PLAN_PATH: ${{ runner.temp }}/release-metadata/release-note-plan.json
RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
RELEASE_STORAGE_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
RELEASE_STORAGE_ENDPOINT: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
RELEASE_STORAGE_REGION: auto
RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
run: |
pnpm exec tools-release prepare-release-note
pnpm exec tools-release publish-release-note
pnpm exec tools-release verify-release-note
- name: Publish prerelease metadata
env:
BASE_VERSION: ${{ needs.metadata.outputs.base_version }}
ENABLE_LINUX_X64: ${{ needs.build_linux.result == 'success' }}
ENABLE_MAC_ARM64: "true"
ENABLE_MAC_X64: ${{ needs.build_mac_intel.result == 'success' }}
ENABLE_WIN_X64: "true"
LINUX_X64_RESULT: ${{ needs.build_linux.result }}
MAC_ARM64_RESULT: ${{ needs.build_mac.result }}
MAC_X64_RESULT: ${{ needs.build_mac_intel.result }}
RELEASE_ASSET_SUFFIX: ""
RELEASE_CHANNEL: prerelease
RELEASE_LAUNCHER_VERSION_MIN_PRERELEASE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_PRERELEASE }}
RELEASE_LAUNCHER_VERSION_MIN_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_STABLE }}
RELEASE_LAUNCHER_VERSION_MIN_URL_PRERELEASE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_PRERELEASE }}
RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE }}
RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests
RELEASE_METADATA_DIR: ${{ runner.temp }}/release-metadata
RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json
RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-metadata/outputs.json
RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_SIGNED: ${{ inputs.mac_sign_mode != 'no' && 'true' || 'false' }}
RELEASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
RELEASE_STORAGE_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
RELEASE_STORAGE_ENDPOINT: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
RELEASE_STORAGE_REGION: auto
RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
STATE_SOURCE: ${{ needs.metadata.outputs.state_source }}
WIN_X64_RESULT: ${{ needs.build_win.result }}
run: pnpm exec tools-release publish-metadata
- name: Read metadata outputs
id: outputs
uses: actions/github-script@v8
with:
script: |
const fs = require("fs");
const outputs = JSON.parse(fs.readFileSync(`${process.env.RUNNER_TEMP}/release-metadata/outputs.json`, "utf8"));
for (const [key, value] of Object.entries(outputs)) core.setOutput(key, String(value));
- name: Verify prerelease metadata
env:
ENABLE_LINUX_X64: ${{ needs.build_linux.result == 'success' }}
ENABLE_MAC_ARM64: "true"
ENABLE_MAC_X64: ${{ needs.build_mac_intel.result == 'success' }}
ENABLE_WIN_X64: "true"
LINUX_X64_RESULT: ${{ needs.build_linux.result }}
MAC_ARM64_RESULT: ${{ needs.build_mac.result }}
MAC_X64_RESULT: ${{ needs.build_mac_intel.result }}
RELEASE_CHANNEL: prerelease
RELEASE_LAUNCHER_VERSION_MIN_PRERELEASE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_PRERELEASE }}
RELEASE_LAUNCHER_VERSION_MIN_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_STABLE }}
RELEASE_LAUNCHER_VERSION_MIN_URL_PRERELEASE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_PRERELEASE }}
RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE }}
RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }}
RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
WIN_X64_RESULT: ${{ needs.build_win.result }}
run: pnpm exec tools-release verify-metadata
- name: Write prerelease metadata summary
env:
RELEASE_CHANNEL: prerelease
RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }}
RELEASE_SUMMARY_PATH: ${{ runner.temp }}/release-metadata/summary.md
run: pnpm exec tools-release summary-metadata
- name: Publish summary
run: cat "${{ runner.temp }}/release-metadata/summary.md" >> "$GITHUB_STEP_SUMMARY"
- name: Cleanup workflow artifacts
if: ${{ success() }}
run: bash .github/scripts/release/github/cleanup-artifacts.sh
dispatch_smoke:
name: Dispatch packaged smoke
# Packaged smoke needs a package, so unlike dispatch_validation this one
# waits for `publish` — but it only waits long enough to POST the dispatch.
# The smoke itself downloads the artifacts back out of R2 in
# release-prerelease-smoke.yml, which is both closer to what a user installs
# and completely off this workflow's concurrency group.
needs:
- metadata
- publish
# `always() && !cancelled()` is load-bearing, not decoration.
#
# `build_linux` is skipped on every run (the repository has no
# ENABLE_STABLE_LINUX variable), and GitHub applies a skip "to all jobs in
# the dependency chain from the point of failure or skip onwards" unless a
# job uses a conditional expression that causes it to continue. That break
# is per job and is NOT inherited: `publish` carries the same prefix and so
# runs, but its own dependents are still downstream of build_linux's skip.
# Without a break of its own this job never evaluates the conditions below
# at all — GitHub's implicit `success()` skips it first.
#
# That is how release-prerelease-smoke.yml went 0-for-N. In run
# 34149795952 publish succeeded, version_metadata_url was published, and
# enable_smoke arrived as true; the job was still skipped with zero steps.
# The explicit publish gate below is what actually decides whether smoke is
# dispatched, and it is unchanged.
if: >-
${{
always() && !cancelled() &&
inputs.enable_smoke &&
needs.publish.result == 'success' &&
needs.publish.outputs.version_metadata_url != ''
}}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
PRIMARY_REF: ${{ needs.metadata.outputs.branch }}
FALLBACK_REF: ${{ github.event.repository.default_branch || 'main' }}
steps:
- name: Checkout dispatch helper
uses: actions/checkout@v6.0.2
with:
# Deliberately the ref this workflow was loaded from, NOT the built
# commit. The helper is infrastructure that ships with this file, so
# the two are always in step; pinning it to the built commit would
# break the moment a release branch carried an older .github tree.
fetch-depth: 1
- name: Dispatch packaged smoke
env:
COMMIT: ${{ needs.metadata.outputs.commit }}
VERSION: ${{ needs.metadata.outputs.release_version }}
VERSION_METADATA_URL: ${{ needs.publish.outputs.version_metadata_url }}
WIN_X64_SMOKE_MODE: ${{ inputs.win_x64_smoke_mode }}
WIN_X64_UPDATE_METADATA_URL: ${{ inputs.win_x64_update_metadata_url }}
WIN_X64_UPDATE_TARGET_VERSION: ${{ inputs.win_x64_update_target_version }}
run: |
bash .github/scripts/release/dispatch-validation.sh release-prerelease-smoke.yml \
-f "commit=$COMMIT" \
-f "version=$VERSION" \
-f "origin_run_id=$GITHUB_RUN_ID" \
-f "version_metadata_url=$VERSION_METADATA_URL" \
-f "win_x64_smoke_mode=$WIN_X64_SMOKE_MODE" \
-f "win_x64_update_metadata_url=$WIN_X64_UPDATE_METADATA_URL" \
-f "win_x64_update_target_version=$WIN_X64_UPDATE_TARGET_VERSION"
cleanup_partial_release_assets:
name: Cleanup unpublished prerelease asset artifacts
needs:
- metadata
- build_mac
- build_mac_intel
- build_win
- build_linux
- publish
if: ${{ always() && needs.publish.result != 'success' }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
ref: ${{ needs.metadata.outputs.commit || inputs.ref || github.ref }}
- name: Delete unpublished prerelease asset artifacts
env:
ARTIFACT_CLEANUP_DESCRIPTION: intermediate prerelease asset Actions artifacts from this unpublished run. Canonical manual downloads are only the R2 links in a successful publish summary
ARTIFACT_NAME_REGEX: "-release-assets$"
run: bash .github/scripts/release/github/cleanup-artifacts.sh