Files
nexu-io__open-design/.github/workflows/release-stable.yml
lefarcen 5a7bbe5b10 chore(release): stop blocking stable releases on missing release notes (#7886)
A stable release with no docs/CHANGELOG/v<version>/ directory hard-failed at
the very first job. Fixing it mid-release is not "write two markdown files":
the stable promotion gate pins github.commit to the promoted prerelease, so the
changelog commit invalidates that artifact and forces a fresh ~27-minute
prerelease round trip.

Missing notes are now a loud warning instead of a failure. reviewReleaseNotePlanPolicy
returns warnings for a stable release with no notes, or one missing zh-CN, and
prepare-release-note surfaces them as a ::warning:: annotation plus a job
summary entry. Notes that exist but cannot be consumed still throw: a channel
mismatch, or a plan that omits the default locale every consumer falls back to.

publish-metadata carried a second stable gate on the same condition. Removing
only the first would have moved the hard failure past the GitHub draft release
and asset upload, which is strictly worse, so both are gone. The rest of the
chain already treated "absent" as a complete outcome — it is the beta and
prerelease path — so publication, verification, and metadata assembly are
unchanged.

Non-stable channels behave exactly as before.
2026-09-08 08:23:56 +00:00

1466 lines
66 KiB
YAML

name: release-stable
on:
workflow_dispatch:
inputs:
prerelease_version:
description: "Exact validated prerelease version to promote, for example 0.5.1-prerelease.3."
required: true
type: string
dry_run:
description: "Release mode. metadata stops after promotion metadata; prepublish runs build/smoke/report/plan without publishing; publish performs the stable release."
required: false
type: choice
options:
- metadata
- prepublish
- publish
default: metadata
ref:
description: "Stable release branch to build, for example release/v0.5.1. Empty builds the dispatch ref, which must also be release/vX.Y.Z."
required: false
type: string
default: ""
win_x64_smoke_mode:
description: "Windows x64 smoke coverage."
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:
prerelease_version:
description: "Exact validated prerelease version to promote."
required: true
type: string
dry_run:
description: "Release mode: metadata, prepublish, or publish. The legacy false value is also accepted for callers."
required: false
type: string
default: "metadata"
ref:
description: "Stable release branch to build. Empty builds the caller's ref, which must also be release/vX.Y.Z."
required: false
type: string
default: ""
win_x64_smoke_mode:
description: "Windows x64 smoke coverage: 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 stable version that was validated or published."
value: ${{ jobs.metadata.outputs.release_version }}
channel:
description: "The stable release 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 }}
dry_run:
description: "Whether this run is a dry-run."
value: ${{ jobs.metadata.outputs.dry_run }}
dry_run_mode:
description: "The stable dry-run boundary: metadata or prepublish."
value: ${{ jobs.metadata.outputs.dry_run_mode }}
previous_commit:
description: "Commit SHA of the previously published prerelease (changelog baseline); empty on cold start."
value: ${{ jobs.metadata.outputs.previous_commit }}
version_metadata_url:
description: "Public R2 URL of this build's version metadata.json."
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 (Intel) 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 (empty if linux disabled)."
value: ${{ jobs.publish.outputs.linux_url }}
permissions:
actions: write
contents: write
packages: write
concurrency:
group: open-design-release-stable-${{ inputs.dry_run }}
cancel-in-progress: false
env:
# Workspace Team's four vela transports are gated in the packaged runtime on
# BOTH a known AMR profile and a non-empty vela web origin
# (workspaceTeamTransportEnv in apps/packaged/src/workspace-team.ts). Neither
# has a default in tools-pack, so a lane that omits them ships a client whose
# team features are silently dormant: the build succeeds, the app starts, and
# the gap only shows up in a user's hands.
#
# Unlike beta/prerelease these are production channels, so the profile is
# pinned rather than taken from an input — there is no legitimate stable or
# preview build aimed at the test backend. Naming prod explicitly is a no-op
# for AMR itself (apps/daemon/src/integrations/vela-profile.ts already
# defaults to prod); it exists to satisfy the first half of the gate. A fork
# or PR build without the secret leaves the origin empty, which keeps the
# transports dormant rather than half-enabled.
OPEN_DESIGN_AMR_PROFILE: prod
OD_VELA_WEB_URL: ${{ 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 }}
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 }}
# Run/repo attribution for the published metadata + per-platform manifests,
# set workflow-wide so the platform build jobs and the publish-metadata step
# agree on github.runId — publish-metadata.ts refuses a platform manifest
# whose github.{runId,commit} disagree with the publish step's. These are the
# same for caller and callee under workflow_call, so they're safe here. Branch
# is NOT: under workflow_call (or inputs.ref) github.ref_name is the caller's
# ref, so RELEASE_BRANCH is passed per publish step as the resolved
# needs.metadata.outputs.branch instead, matching what the metadata job built.
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_RUN_ATTEMPT: ${{ github.run_attempt }}
RELEASE_RUN_ID: ${{ github.run_id }}
RELEASE_WORKFLOW: ${{ github.workflow }}
jobs:
metadata:
name: Prepare release metadata
if: github.repository == 'nexu-io/open-design'
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
OPEN_DESIGN_PRERELEASE_METADATA_URL: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}/prerelease/latest/metadata.json
OPEN_DESIGN_RELEASE_DRY_RUN: ${{ inputs.dry_run == 'publish' && 'false' || inputs.dry_run }}
OPEN_DESIGN_RELEASE_CHANNEL: stable
OPEN_DESIGN_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
OPEN_DESIGN_STABLE_PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
outputs:
base_version: ${{ steps.stable.outputs.base_version }}
branch: ${{ steps.stable.outputs.branch }}
channel: ${{ steps.stable.outputs.channel }}
commit: ${{ steps.stable.outputs.commit }}
dry_run: ${{ steps.stable.outputs.dry_run }}
dry_run_mode: ${{ steps.stable.outputs.dry_run_mode }}
github_release_enabled: ${{ steps.stable.outputs.github_release_enabled }}
linux_namespace: ${{ steps.stable.outputs.linux_namespace }}
mac_intel_namespace: ${{ steps.stable.outputs.mac_intel_namespace }}
namespace: ${{ steps.stable.outputs.namespace }}
publish_side_effects_enabled: ${{ steps.stable.outputs.publish_side_effects_enabled }}
prerelease_number: ${{ steps.stable.outputs.prerelease_number }}
previous_stable: ${{ steps.stable.outputs.previous_stable }}
previous_commit: ${{ steps.prev.outputs.previous_commit }}
release_name: ${{ steps.stable.outputs.release_name }}
release_version: ${{ steps.stable.outputs.release_version }}
run_prepublish_jobs: ${{ steps.stable.outputs.run_prepublish_jobs }}
stable_version: ${{ steps.stable.outputs.stable_version }}
state_source: ${{ steps.stable.outputs.state_source }}
version_tag: ${{ steps.stable.outputs.version_tag }}
win_namespace: ${{ steps.stable.outputs.win_namespace }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
ref: ${{ inputs.ref != '' && inputs.ref || github.ref }}
fetch-depth: 0
- 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
# Under workflow_call the runner's GITHUB_SHA/GITHUB_REF_NAME describe the
# caller's ref (e.g. main for the scheduled daily build), not the ref we
# actually checked out. Resolve the real built HEAD so the published
# metadata.json records the branch we built, not the caller.
- name: Resolve built commit
run: echo "BUILT_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
# Capture the previously published prerelease commit BEFORE this run overwrites
# prerelease/latest/metadata.json, so the notification can diff the changelog
# against the last package. Empty on cold start (HTTP 404).
- 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 release metadata
id: stable
env:
GITHUB_SHA: ${{ env.BUILT_SHA }}
GITHUB_REF_NAME: ${{ inputs.ref != '' && inputs.ref || github.ref_name }}
run: pnpm exec tools-release prepare stable
# Reports missing or partial stable release notes as a run annotation and
# a job-summary entry. It is deliberately not a gate: see
# docs/CHANGELOG/README.md.
- name: Check stable release note coverage
env:
RELEASE_CHANNEL: stable
RELEASE_NOTE_PLAN_PATH: ${{ runner.temp }}/release-note-preflight/plan.json
RELEASE_VERSION: ${{ steps.stable.outputs.release_version }}
run: pnpm exec tools-release prepare-release-note
- name: Validate R2 release access
if: ${{ steps.stable.outputs.publish_side_effects_enabled == 'true' }}
env:
GITHUB_SHA: ${{ env.BUILT_SHA }}
R2_ACCESS_PROBE_NAME: ${{ steps.stable.outputs.namespace }}
RELEASE_CHANNEL: stable
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
verify:
name: Verify build (typecheck + tests)
needs: metadata
if: ${{ needs.metadata.outputs.run_prepublish_jobs == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- 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
# `scripts/postinstall.mjs` auto-builds `packages/*` and `tools/*`, but
# `apps/daemon` and `apps/desktop` are not in that list. On a fresh clone
# (every CI run), workspace typecheck fails because:
# - packaged/runtime consumers resolve the daemon package export through
# generated `apps/daemon/dist/*.d.ts`
# - `apps/packaged/src/index.ts` dynamic-imports `@open-design/desktop/main`
# which resolves to `apps/desktop/dist/main/index.d.ts`
# Build them explicitly here. Keeps the root `typecheck` script untouched.
- name: Build daemon and desktop (typecheck dependencies)
run: |
pnpm --filter @open-design/daemon build
pnpm --filter @open-design/desktop build
- name: Typecheck workspaces
run: pnpm -r --workspace-concurrency=4 --if-present run typecheck
- name: Check repository layout policies
run: pnpm guard
# Workspace tests are intentionally not gated here. apps/web's
# i18n content-coverage tests assert that every locale carries
# display metadata for every prompt template / skill / design
# system. Those tests fail on `main` as of this writing because
# PR #187 added two new prompt templates without translating
# their metadata into the 9 ship-ready locales — an i18n drift
# that's out of scope for the release infrastructure. Tracked as
# a follow-up; revisit once locale metadata is back in sync.
build_mac:
name: Build release mac arm64
needs: [metadata, verify]
if: ${{ needs.metadata.outputs.run_prepublish_jobs == 'true' }}
runs-on: macos-14
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- 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: 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
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 release mac artifacts
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
tools_pack_dir="$RUNNER_TEMP/tools-pack"
build_json_path="$RUNNER_TEMP/mac-tools-pack-build.json"
build_log_path="$RUNNER_TEMP/mac-tools-pack-build.log"
rm -rf "$tools_pack_dir"
: > "$build_log_path"
build_args=(
exec tools-pack mac build
--dir "$tools_pack_dir"
--namespace "${{ needs.metadata.outputs.namespace }}"
--portable
--app-version "${{ needs.metadata.outputs.release_version }}"
--mac-compression normal
--to all
--require-vela-cli
--json
--signed
--notarize
)
if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$build_log_path" >&2))"; then
printf '%s\n' "$build_output" | tee "$build_json_path"
else
build_status=$?
printf '%s\n' "$build_output"
exit "$build_status"
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)"
app_name="Open Design.app"
if [ "${{ needs.metadata.outputs.channel }}" = "prerelease" ]; then
app_name="Open Design Prerelease.app"
fi
built_framework="$RUNNER_TEMP/tools-pack/out/mac/namespaces/${{ needs.metadata.outputs.namespace }}/builder/mac-arm64/$app_name/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-stable-mac-build-diagnostics
path: |
${{ runner.temp }}/mac-tools-pack-build.log
${{ runner.temp }}/mac-tools-pack-build.json
${{ runner.temp }}/mac-framework-diagnostics.txt
if-no-files-found: warn
- name: Smoke release mac packaged runtime
working-directory: e2e
env:
OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/mac-tools-pack-build.json
OD_PACKAGED_E2E_BUILD_LOG_PATH: ${{ runner.temp }}/mac-tools-pack-build.log
OD_PACKAGED_E2E_MAC: "1"
OD_PACKAGED_E2E_MAC_ONBOARDING_SMOKE: ${{ needs.metadata.outputs.channel == 'nightly' && '1' || '0' }}
OD_PACKAGED_E2E_NAMESPACE: ${{ needs.metadata.outputs.namespace }}
OD_PACKAGED_E2E_RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/mac
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
run: |
set -euo pipefail
pnpm exec tsx scripts/release-smoke.ts mac specs/mac.spec.ts
- name: Upload mac e2e spec report
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: open-design-release-mac-e2e-report
path: ${{ runner.temp }}/release-report/mac
if-no-files-found: warn
- name: Prepare mac release assets
id: assets
env:
RELEASE_ARTIFACT_MODE: all
RELEASE_ASSET_SUFFIX: ""
RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets
RELEASE_BRANCH: ${{ needs.metadata.outputs.branch }}
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_NAMESPACE: ${{ needs.metadata.outputs.namespace }}
RELEASE_NOTES: OpenDesign ${{ 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 platform
env:
RELEASE_ARTIFACT_MODE: all
RELEASE_ASSET_SUFFIX: ""
RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets
RELEASE_BRANCH: ${{ needs.metadata.outputs.branch }}
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }}
RELEASE_DRY_RUN_MODE: ${{ needs.metadata.outputs.dry_run_mode }}
RELEASE_PUBLISH_SIDE_EFFECTS: ${{ needs.metadata.outputs.publish_side_effects_enabled }}
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
RELEASE_REPORT_ZIP_PATH: ${{ runner.temp }}/release-report/mac-report.zip
RELEASE_SIGNED: "true"
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 publish manifest
uses: actions/upload-artifact@v7
with:
name: open-design-release-mac-arm64-publish-manifest
path: ${{ runner.temp }}/release-platform-manifests/mac_arm64.json
if-no-files-found: error
retention-days: 1
- name: Upload mac release bundle
uses: actions/upload-artifact@v7
with:
name: open-design-release-mac-release-assets
path: ${{ runner.temp }}/release-assets
retention-days: 1
build_mac_intel:
name: Build release mac intel x64
needs: [metadata, verify]
if: ${{ needs.metadata.outputs.run_prepublish_jobs == 'true' }}
runs-on: macos-15-intel
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- 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 Apple signing certificate
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 release mac intel artifacts
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
pnpm exec tools-pack mac build \
--dir "$RUNNER_TEMP/tools-pack" \
--namespace "${{ needs.metadata.outputs.mac_intel_namespace }}" \
--portable \
--app-version "${{ needs.metadata.outputs.release_version }}" \
--mac-compression normal \
--to all \
--require-vela-cli \
--json \
--signed \
--notarize
- name: Prepare mac intel release assets
id: assets
env:
RELEASE_ARTIFACT_MODE: all
RELEASE_ASSET_SUFFIX: ""
RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets
RELEASE_BRANCH: ${{ needs.metadata.outputs.branch }}
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_NAMESPACE: ${{ needs.metadata.outputs.mac_intel_namespace }}
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 platform
env:
RELEASE_ARTIFACT_MODE: all
RELEASE_ASSET_SUFFIX: ""
RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets
RELEASE_BRANCH: ${{ needs.metadata.outputs.branch }}
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }}
RELEASE_DRY_RUN_MODE: ${{ needs.metadata.outputs.dry_run_mode }}
RELEASE_PUBLISH_SIDE_EFFECTS: ${{ needs.metadata.outputs.publish_side_effects_enabled }}
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_SIGNED: "true"
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 publish manifest
uses: actions/upload-artifact@v7
with:
name: open-design-release-mac-x64-publish-manifest
path: ${{ runner.temp }}/release-platform-manifests/mac_x64.json
if-no-files-found: error
retention-days: 1
- name: Upload mac intel release bundle
uses: actions/upload-artifact@v7
with:
name: open-design-release-mac-intel-release-assets
path: ${{ runner.temp }}/release-assets
retention-days: 1
build_win:
name: Build release win x64
needs: [metadata, verify]
if: ${{ needs.metadata.outputs.run_prepublish_jobs == 'true' }}
runs-on: windows-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- 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
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- 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-stable.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-v7-stable-$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: |
if ((Get-Command makensis.exe -ErrorAction SilentlyContinue) -or (Test-Path "C:\Program Files (x86)\NSIS\makensis.exe")) {
exit 0
}
choco install nsis -y --no-progress
- name: Build release 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 "windows-tools-pack-build.json"
pnpm.cmd exec tools-pack win cleanup --dir $toolsPackDir --namespace "${{ needs.metadata.outputs.win_namespace }}" --json
$buildArgs = @(
"exec", "tools-pack", "win", "build",
"--dir", $toolsPackDir,
"--cache-dir", $cacheDir,
"--namespace", "${{ needs.metadata.outputs.win_namespace }}",
"--portable",
"--app-version", "${{ needs.metadata.outputs.release_version }}",
"--to", "all",
"--require-vela-cli",
"--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 "${{ needs.metadata.outputs.win_namespace }}" --json
$buildOutput = pnpm exec tools-pack win build `
--dir $toolsPackDir `
--cache-dir $cacheDir `
--namespace "${{ needs.metadata.outputs.win_namespace }}" `
--portable `
--app-version "${{ needs.metadata.outputs.release_version }}" `
--to all `
--require-vela-cli `
--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 "${{ needs.metadata.outputs.win_namespace }}" --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: Build stable win_x64 update fixture
if: ${{ inputs.win_x64_smoke_mode == 'full' && inputs.win_x64_update_metadata_url == '' && inputs.win_x64_update_target_version == '' }}
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$match = [regex]::Match("${{ needs.metadata.outputs.release_version }}", "^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)$")
if (-not $match.Success) { throw "full Windows stable smoke requires stable version x.y.z; got ${{ needs.metadata.outputs.release_version }}" }
$updateVersion = "{0}.{1}.{2}" -f $match.Groups["major"].Value, $match.Groups["minor"].Value, ([int]$match.Groups["patch"].Value + 1)
$buildArgs = @("exec", "tools-pack", "win", "build", "--dir", "${{ runner.temp }}\release-work\win_x64\tools-pack-update-fixture", "--cache-dir", "${{ runner.temp }}\tools-pack-cache", "--namespace", "${{ needs.metadata.outputs.win_namespace }}", "--app-version", $updateVersion, "--to", "nsis", "--require-vela-cli", "--json")
$updateOutput = pnpm.cmd @buildArgs
if ($LASTEXITCODE -ne 0) { throw "tools-pack win update fixture failed with exit code $LASTEXITCODE" }
$updateOutput | Set-Content -Path "${{ runner.temp }}\windows-tools-pack-update-build.json" -Encoding utf8
$updateBuild = $updateOutput | ConvertFrom-Json
pnpm.cmd exec tools-pack win validate-payload --namespace "${{ needs.metadata.outputs.win_namespace }}" --payload-path $updateBuild.payloadPath --expected-version $updateVersion --json
- name: Smoke release windows packaged runtime
if: ${{ inputs.win_x64_smoke_mode != 'skip' }}
working-directory: e2e
env:
OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/windows-tools-pack-build.json
OD_PACKAGED_E2E_WIN: "1"
OD_PACKAGED_E2E_WIN_ONBOARDING_SMOKE: ${{ needs.metadata.outputs.channel == 'nightly' && '1' || '0' }}
OD_PACKAGED_E2E_NAMESPACE: ${{ needs.metadata.outputs.win_namespace }}
OD_PACKAGED_E2E_RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/win
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
OD_PACKAGED_E2E_WIN_SMOKE_PROFILE: ${{ inputs.win_x64_smoke_mode }}
OD_PACKAGED_E2E_WIN_UPDATE_FIXTURE: ${{ inputs.win_x64_smoke_mode == 'full' && inputs.win_x64_update_metadata_url == '' && inputs.win_x64_update_target_version == '' && 'tools-serve' || '' }}
OD_PACKAGED_E2E_WIN_UPDATE_METADATA_URL: ${{ inputs.win_x64_update_metadata_url }}
OD_PACKAGED_E2E_WIN_UPDATE_VERSION: ${{ inputs.win_x64_update_target_version }}
run: |
$ErrorActionPreference = "Stop"
pnpm exec tsx scripts/release-smoke.ts win specs/win.spec.ts
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
- name: Upload windows e2e spec report
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: open-design-release-win-e2e-report
path: ${{ runner.temp }}/release-report/win
if-no-files-found: warn
- 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 release assets
shell: pwsh
run: |
tools\release\scripts\prepare-platform-assets.ps1 `
-ReleaseTarget win_x64 `
-ReleaseAssetsDir "${{ runner.temp }}\release-assets" `
-BuildJsonPath "${{ runner.temp }}\windows-tools-pack-build.json" `
-ReleaseNamespace "${{ needs.metadata.outputs.win_namespace }}" `
-ReleaseVersion "${{ needs.metadata.outputs.release_version }}" `
-ReleaseAssetSuffix "" `
-ReleaseChannel "${{ needs.metadata.outputs.channel }}" `
-ReleasePublicOrigin "${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}" `
-ReleaseNotes "OpenDesign ${{ needs.metadata.outputs.release_version }}" `
-IncludeZip $true
- name: Publish windows platform
env:
RELEASE_ASSET_SUFFIX: ""
RELEASE_ASSETS_DIR: ${{ runner.temp }}\release-assets
RELEASE_BRANCH: ${{ needs.metadata.outputs.branch }}
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }}
RELEASE_DRY_RUN_MODE: ${{ needs.metadata.outputs.dry_run_mode }}
RELEASE_PUBLISH_SIDE_EFFECTS: ${{ needs.metadata.outputs.publish_side_effects_enabled }}
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
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 publish manifest
uses: actions/upload-artifact@v7
with:
name: open-design-release-win-x64-publish-manifest
path: ${{ runner.temp }}\release-platform-manifests\win_x64.json
if-no-files-found: error
retention-days: 1
- name: Upload windows release bundle
uses: actions/upload-artifact@v7
with:
name: open-design-release-win-release-assets
path: ${{ runner.temp }}/release-assets
retention-days: 1
build_linux:
name: Build release linux x64
needs: [metadata, verify]
# Linux AppImage packaging is temporarily excluded from stable releases.
# Keep the job definition in place so the Linux lane can be re-enabled once
# the containerized pnpm bootstrap is fixed and reviewed.
if: ${{ needs.metadata.outputs.run_prepublish_jobs == 'true' && vars.ENABLE_STABLE_LINUX == 'true' }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- 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
# `--containerized` builds the AppImage inside the electronuserland/builder
# Docker image (glibc 2.27 baseline) so the resulting binary runs on older
# distros than ubuntu-latest's glibc 2.39. Docker is preinstalled on the
# GitHub-hosted ubuntu-latest runner, so no extra setup is required.
- name: Build release linux artifacts
run: |
set -euo pipefail
tools_pack_dir="$RUNNER_TEMP/tools-pack"
report_dir="$RUNNER_TEMP/release-report/linux"
build_json_path="$report_dir/tools-pack.json"
build_log_path="$report_dir/tools-pack.log"
rm -rf "$tools_pack_dir"
mkdir -p "$report_dir"
: > "$build_log_path"
build_args=(
exec tools-pack linux build
--dir "$tools_pack_dir"
--namespace "${{ needs.metadata.outputs.linux_namespace }}"
--portable
--app-version "${{ needs.metadata.outputs.release_version }}"
--to appimage
--containerized
--json
)
if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$build_log_path" >&2))"; then
printf '%s\n' "$build_output" | tee "$build_json_path"
node -e 'const fs = require("node:fs"); JSON.parse(fs.readFileSync(process.argv[1], "utf8"));' "$build_json_path"
else
build_status=$?
printf '%s\n' "$build_output" | tee "$build_json_path"
exit "$build_status"
fi
- name: Smoke release linux AppImage runtime
working-directory: e2e
env:
OD_PACKAGED_E2E_LINUX_APPIMAGE: "1"
OD_PACKAGED_E2E_NAMESPACE: ${{ needs.metadata.outputs.linux_namespace }}
OD_PACKAGED_E2E_SCREENSHOT_PATH: ${{ runner.temp }}/release-report/linux/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"
mkdir -p "$report_dir/screenshots"
cat > "$report_dir/manifest.json" <<EOF
{
"channel": "${{ needs.metadata.outputs.channel }}",
"platform": "linux",
"releaseVersion": "${{ needs.metadata.outputs.release_version }}",
"spec": "specs/linux.spec.ts",
"namespace": "${{ needs.metadata.outputs.linux_namespace }}",
"screenshot": "screenshots/open-design-linux-smoke.png",
"githubRunId": "$GITHUB_RUN_ID",
"githubRunAttempt": "$GITHUB_RUN_ATTEMPT",
"commit": "$GITHUB_SHA"
}
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-release-linux-e2e-report
path: ${{ runner.temp }}/release-report/linux
if-no-files-found: warn
- name: Prepare linux release assets
env:
RELEASE_ASSET_SUFFIX: ""
RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets
RELEASE_BRANCH: ${{ needs.metadata.outputs.branch }}
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_NAMESPACE: ${{ needs.metadata.outputs.linux_namespace }}
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 platform
env:
RELEASE_ASSET_SUFFIX: ""
RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets
RELEASE_BRANCH: ${{ needs.metadata.outputs.branch }}
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }}
RELEASE_DRY_RUN_MODE: ${{ needs.metadata.outputs.dry_run_mode }}
RELEASE_PUBLISH_SIDE_EFFECTS: ${{ needs.metadata.outputs.publish_side_effects_enabled }}
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_REPORT_DIR: ${{ runner.temp }}/release-report/linux
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 publish manifest
uses: actions/upload-artifact@v7
with:
name: open-design-release-linux-x64-publish-manifest
path: ${{ runner.temp }}/release-platform-manifests/linux_x64.json
if-no-files-found: error
retention-days: 1
- name: Upload linux release bundle
uses: actions/upload-artifact@v7
with:
name: open-design-release-linux-release-assets
path: ${{ runner.temp }}/release-assets
retention-days: 1
publish_docker_image:
name: Publish stable Docker image
needs:
- metadata
- publish
if: >-
${{
always() &&
!cancelled() &&
needs.metadata.outputs.dry_run != 'true' &&
needs.metadata.outputs.github_release_enabled == 'true' &&
needs.metadata.result == 'success' &&
needs.publish.result == 'success'
}}
permissions:
contents: read
packages: write
id-token: write
uses: ./.github/workflows/docker-image.yml
secrets: inherit
with:
ref: ${{ needs.metadata.outputs.commit }}
release_version: ${{ needs.metadata.outputs.stable_version }}
publish_latest: true
publish:
name: Finalize ${{ needs.metadata.outputs.channel }} release plan
needs:
- metadata
- verify
- build_mac
- build_mac_intel
- build_win
- build_linux
if: >-
${{
always() &&
!cancelled() &&
needs.metadata.outputs.run_prepublish_jobs == 'true' &&
needs.metadata.result == 'success' &&
needs.verify.result == 'success' &&
needs.build_mac.result == 'success' &&
needs.build_mac_intel.result == 'success' &&
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 }}
# Record the real built commit in metadata.json. Under workflow_call the
# runner's GITHUB_SHA is the caller's ref, so pin it to the resolved build
# commit; the next run reads this back as its changelog baseline.
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_MAC_INTEL: "true"
ENABLE_WIN: "true"
GITHUB_RELEASE_ENABLED: ${{ needs.metadata.outputs.github_release_enabled }}
MAC_INTEL_SIGNED: "true"
PRERELEASE_NUMBER: ${{ needs.metadata.outputs.prerelease_number }}
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_NAME: ${{ needs.metadata.outputs.release_name }}
RELEASE_SIGNED: "true"
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
REPORT_MODE: zip
STATE_SOURCE: ${{ needs.metadata.outputs.state_source }}
VERSION_TAG: ${{ needs.metadata.outputs.version_tag }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download mac publish manifest
uses: actions/download-artifact@v8
with:
name: open-design-release-mac-arm64-publish-manifest
path: ${{ runner.temp }}/release-platform-manifests
- name: Download mac intel publish manifest
uses: actions/download-artifact@v8
with:
name: open-design-release-mac-x64-publish-manifest
path: ${{ runner.temp }}/release-platform-manifests
- name: Download windows publish manifest
uses: actions/download-artifact@v8
with:
name: open-design-release-win-x64-publish-manifest
path: ${{ runner.temp }}/release-platform-manifests
- name: Download linux publish manifest
if: ${{ needs.build_linux.result == 'success' }}
uses: actions/download-artifact@v8
with:
name: open-design-release-linux-x64-publish-manifest
path: ${{ runner.temp }}/release-platform-manifests
- name: Pre-flight tag/release check
if: ${{ needs.metadata.outputs.github_release_enabled == 'true' }}
run: |
set -euo pipefail
if git ls-remote --exit-code --tags origin "refs/tags/$VERSION_TAG" >/dev/null 2>&1; then
echo "tag $VERSION_TAG already exists on origin; aborting" >&2
exit 1
fi
if gh release view "$VERSION_TAG" >/dev/null 2>&1; then
echo "release $VERSION_TAG already exists; aborting" >&2
exit 1
fi
- name: Download mac release bundle
uses: actions/download-artifact@v8
with:
name: open-design-release-mac-release-assets
path: ${{ runner.temp }}/release-assets/mac
- name: Download mac intel release bundle
uses: actions/download-artifact@v8
with:
name: open-design-release-mac-intel-release-assets
path: ${{ runner.temp }}/release-assets/mac-intel
- name: Download windows release bundle
uses: actions/download-artifact@v8
with:
name: open-design-release-win-release-assets
path: ${{ runner.temp }}/release-assets/win
- name: Download linux release bundle
if: ${{ needs.build_linux.result == 'success' }}
uses: actions/download-artifact@v8
with:
name: open-design-release-linux-release-assets
path: ${{ runner.temp }}/release-assets/linux
- name: Download linux e2e spec report
if: ${{ needs.build_linux.result == 'success' }}
uses: actions/download-artifact@v8
with:
name: open-design-release-linux-e2e-report
path: ${{ runner.temp }}/release-report/linux
- name: Download mac e2e spec report
uses: actions/download-artifact@v8
with:
name: open-design-release-mac-e2e-report
path: ${{ runner.temp }}/release-report/mac
# The windows smoke uploads this artifact with `if-no-files-found: warn`,
# so a skipped smoke leaves an empty report directory and publishes no
# artifact at all. Downloading it unconditionally then fails finalize —
# after all three platforms have already built, signed and notarized —
# which makes `win_x64_smoke_mode: skip` unusable on the stable lane even
# though the input offers it. Guard it the way the linux report is guarded
# by its own build result.
- name: Download windows e2e spec report
if: ${{ inputs.win_x64_smoke_mode != 'skip' }}
uses: actions/download-artifact@v8
with:
name: open-design-release-win-e2e-report
path: ${{ runner.temp }}/release-report/win
- name: Prepare stable GitHub Release asset plan
env:
ENABLE_LINUX_X64: ${{ needs.build_linux.result == 'success' }}
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_GITHUB_ASSETS_DIR: ${{ runner.temp }}/github-release-assets
RELEASE_GITHUB_ASSETS_SOURCE_DIR: ${{ runner.temp }}/release-assets
RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/github-release-assets-plan/outputs.json
RELEASE_SUMMARY_PATH: ${{ runner.temp }}/github-release-assets-plan/summary.md
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
run: pnpm exec tools-release prepare-github-assets
- name: Publish GitHub Release asset plan summary
run: cat "${{ runner.temp }}/github-release-assets-plan/summary.md" >> "$GITHUB_STEP_SUMMARY"
- name: Write stable release notes
id: notes
if: ${{ needs.metadata.outputs.github_release_enabled == 'true' }}
env:
RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
run: bash .github/scripts/release/github/stable-notes.sh
- name: Create draft release with tag
id: create_release
if: ${{ needs.metadata.outputs.github_release_enabled == 'true' }}
run: |
set -euo pipefail
# gh release create creates the tag at $GITHUB_SHA atomically with the release.
# Using --draft keeps the release invisible until R2 publish and asset upload verify successfully.
gh release create "$VERSION_TAG" \
--target "$GITHUB_SHA" \
--title "$RELEASE_NAME" \
--notes-file "${{ steps.notes.outputs.notes_file }}" \
--draft
- name: Upload assets to draft release
if: ${{ needs.metadata.outputs.github_release_enabled == 'true' }}
run: |
set -euo pipefail
gh release upload "$VERSION_TAG" "$RUNNER_TEMP/github-release-assets"/*
- name: Prepare and publish stable release notes
env:
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
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_PUBLISH_SIDE_EFFECTS: ${{ needs.metadata.outputs.publish_side_effects_enabled }}
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: Prepare release metadata
env:
BASE_VERSION: ${{ needs.metadata.outputs.base_version }}
ENABLE_LINUX_X64: ${{ needs.build_linux.result == 'success' }}
ENABLE_MAC_ARM64: "true"
ENABLE_MAC_X64: "true"
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_BRANCH: ${{ needs.metadata.outputs.branch }}
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }}
RELEASE_DRY_RUN_MODE: ${{ needs.metadata.outputs.dry_run_mode }}
RELEASE_LAUNCHER_VERSION_MIN_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_STABLE }}
RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE }}
RELEASE_PUBLISH_SIDE_EFFECTS: ${{ needs.metadata.outputs.publish_side_effects_enabled }}
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_REPOSITORY: ${{ github.repository }}
RELEASE_RUN_ATTEMPT: ${{ github.run_attempt }}
RELEASE_RUN_ID: ${{ github.run_id }}
RELEASE_SIGNED: "true"
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 }}
RELEASE_WORKFLOW: ${{ github.workflow }}
STABLE_VERSION: ${{ needs.metadata.outputs.stable_version }}
STATE_SOURCE: ${{ needs.metadata.outputs.state_source }}
VERSION_TAG: ${{ needs.metadata.outputs.version_tag }}
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 release metadata
env:
ENABLE_LINUX_X64: ${{ needs.build_linux.result == 'success' }}
ENABLE_MAC_ARM64: "true"
ENABLE_MAC_X64: "true"
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_DRY_RUN_MODE: ${{ needs.metadata.outputs.dry_run_mode }}
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_LAUNCHER_VERSION_MIN_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_STABLE }}
RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE }}
RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }}
RELEASE_METADATA_PATH: ${{ needs.metadata.outputs.publish_side_effects_enabled == 'true' && '' || format('{0}/release-metadata/metadata.json', runner.temp) }}
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: Promote draft to published latest
if: ${{ needs.metadata.outputs.github_release_enabled == 'true' }}
run: |
set -euo pipefail
gh release edit "$VERSION_TAG" \
--draft=false \
--latest
- name: Cleanup release + tag on failure
if: ${{ failure() && needs.metadata.outputs.github_release_enabled == 'true' && steps.create_release.outcome == 'success' }}
run: |
set +e
echo "publish failed after release was created; rolling back release and tag"
gh release delete "$VERSION_TAG" --cleanup-tag --yes
# belt-and-suspenders: ensure remote tag is gone even if --cleanup-tag missed
git push origin --delete "refs/tags/$VERSION_TAG" || true
- name: Publish summary
env:
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }}
RELEASE_METADATA_PATH: ${{ needs.metadata.outputs.publish_side_effects_enabled == 'true' && '' || format('{0}/release-metadata/metadata.json', runner.temp) }}
RELEASE_SUMMARY_PATH: ${{ runner.temp }}/release-metadata/summary.md
run: |
pnpm exec tools-release summary-metadata
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
cleanup_partial_release_assets:
name: Cleanup unpublished release asset artifacts
needs:
- metadata
- build_mac
- build_mac_intel
- build_win
- build_linux
- publish
if: ${{ always() && needs.metadata.outputs.dry_run != 'true' && needs.publish.result != 'success' }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
ref: ${{ inputs.ref }}
- name: Delete unpublished release asset artifacts
env:
ARTIFACT_CLEANUP_DESCRIPTION: intermediate release 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