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:-}" 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" <&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