Files
nexu-io__open-design/.github/workflows/release-prerelease.yml
lefarcen faba302437 feat(release): alert through the other Feishu bot when the prerelease card goes silent (#7873)
* feat(release): alert through the other Feishu bot when the prerelease card goes silent

The progressive card is now the only prerelease notification, and every way it
breaks is silent: the pipeline stays green, the packages ship, and the channel
hears nothing. The watcher catches Feishu failures on purpose — one hiccup must
not end a two-hour watch — so expired application-bot credentials look exactly
like a healthy run.

Adds a last-resort notice delivered through the custom-bot webhook, which is a
different bot with a different secret, so a credential failure on the card side
cannot take the alert down with it. It fires only when the card did not reach
the group: the watcher now reports `card_delivered` for a card whose FINAL state
is in the chat, and the gate reads that rather than the job result.

Two callers, mutually exclusive so a release can never draw both:
release-prerelease-card.yml's `fallback_notice` job covers a watcher that died
or never delivered; steps at the end of release-prerelease.yml's
`dispatch_validation` cover a card that was never dispatched. The latter are
steps, not a job, because nothing may appear in a `needs:` on the dispatchers.

* fix(release): keep the fallback notice reachable past the dispatch it reports

The two steps that actually deliver the notice carried no status function, so
GitHub's implicit success() skipped them whenever anything earlier in the job
had failed. `dispatch-validation.sh` exits non-zero when it cannot dispatch the
card on any ref — which marks the job failed — so on the one path the
dispatch-side fallback exists for, it was silent. This PR was written to close
the implicit-success() trap at the job level and walked into its step-level twin.

Both consumers now carry `always() && steps.notice.outcome == 'success'`: the
status function so a failed dispatch still reaches them, and the outcome check
so neither a crashed nor a skipped composer can hand feishu-notice.ts an empty
body. The composer writes `alert` last, after every field a poster reads, so
`alert=true` in an append-only $GITHUB_OUTPUT means the whole notice is on disk.

Adds the step-level reachability rule as a spec beside the existing job-level
one, and puts the dispatched prerelease lanes into that job-level sweep —
release-prerelease-card.yml was outside it, so `fallback_notice`, whose `if`
hand-checks `needs.card.result`, was uncovered by the rule it has to obey.
2026-09-08 08:24:00 +00:00

1782 lines
90 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') }}
run: |
if ([string]::IsNullOrWhiteSpace($env:WIN_TOOLS_PACK_ORIGIN_KEY)) {
throw "Windows tools-pack cache origin key is empty"
}
$prefix = "tools-pack-win-v1-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: Prune Windows tools-pack cache
shell: pwsh
continue-on-error: true
run: ./.github/scripts/release/cache/win.ps1
- name: Save Windows tools-pack cache
if: ${{ success() && (steps.win_tools_pack_cache_restore.outputs.cache-hit != 'true' || steps.win_tools_pack_build.outputs.cache_failed == 'true') }}
uses: actions/cache/save@v5
continue-on-error: true
with:
path: ${{ runner.temp }}/tools-pack-cache
key: ${{ steps.win_tools_pack_cache_key.outputs.key }}
- name: Retain recent Windows tools-pack caches
if: ${{ success() }}
shell: pwsh
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
$prefix = "${{ steps.win_tools_pack_cache_key.outputs.prefix }}"
$keep = 1
$caches = @(gh cache list --key $prefix --sort created_at --order desc --limit 100 --json id,key,createdAt | ConvertFrom-Json)
$stale = @($caches | Select-Object -Skip $keep)
foreach ($cache in $stale) {
gh cache delete $cache.id
}
"actionsCachePrefix=$prefix kept=$([Math]::Min($caches.Count, $keep)) deleted=$($stale.Count)"
- 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.
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