open-design/.github/workflows/release-stable.yml
lefarcen ebf4a3ffca
feat(release): upload browser sourcemaps to PostHog for packaged builds (#2508)
* i18n: add translations for media provider coming soon section (#2415)

* i18n: add translations for media provider coming soon section

- Add 'settings.mediaProviderComingSoonHint' key to all 19 locales
- Replace hardcoded English strings in SettingsDialog.tsx with i18n keys
- Reuse existing 'tasks.comingSoon' and 'settings.agentInstall.docs' keys
- Resolves TODO(i18n) comment at line 5091

* fix: escape single quotes in translation strings

* fix: escape all single quotes in English translation string

* feat(release): upload browser sourcemaps to PostHog for packaged builds

Next.js was emitting minified JS with no browser sourcemaps, so PostHog
Error Tracking surfaces frames like fO / fz / s4 / tD instead of real
file:line locations. This wires up the full pipeline:

- apps/web/next.config.ts: enable productionBrowserSourceMaps so next build
  emits .js.map alongside each chunk.
- tools/pack/src/web-sourcemaps.ts: new helper that runs after next build
  and before any packaging step copies the web output into the Electron
  resources. Uses @posthog/cli to inject chunk IDs and upload sourcemaps
  to PostHog, then ALWAYS strips every .map under .next/static so source
  never ships inside an installer (saves ~14 MB per packaged image too).
- tools/pack/src/{mac/workspace,win/app,linux}.ts: call processWebSourcemaps
  immediately after the @open-design/web build step.
- tools/pack/src/config.ts: read POSTHOG_CLI_API_KEY + POSTHOG_CLI_PROJECT_ID
  (with POSTHOG_PERSONAL_API_KEY / POSTHOG_PROJECT_ID aliases) and expose
  them on ToolPackConfig with the same shape as the existing posthogKey /
  posthogHost fields.
- .github/workflows/release-{beta,preview,stable}.yml: pass the new secrets
  through so all three release channels symbolicate stacks.

When the API key is missing (PR builds, forks, local contributor builds),
the helper logs and skips the upload — but still strips .map files. The
strip step is unconditional because shipping a sourcemap is equivalent to
shipping the source.

Adds tools/pack/tests/web-sourcemaps.test.ts covering: missing chunks dir
silently noop, no-map noop, strip-only path when credentials are absent,
recursive walker for nested subdirectories. CLI happy path is left to the
release workflow itself.

Required follow-up (cannot push from code): add a repo secret named
POSTHOG_CLI_API_KEY (the phx_ personal API key) and a repo var named
POSTHOG_CLI_PROJECT_ID (the numeric project id, 420348 for our project)
in nexu-io/open-design settings before merging.

* fix(web-sourcemaps): use management host for CLI, not ingest host

POSTHOG_HOST is the ingest URL (us.i.posthog.com) used by the runtime SDK
to POST events to /capture/. The @posthog/cli sourcemap upload talks to
the **management** API (us.posthog.com) and gets a 404 on the ingest
host. The two are not interchangeable.

Adds a separate `posthogCliHost` field on ToolPackConfig sourced from
POSTHOG_CLI_HOST (with no fallback to POSTHOG_HOST). When the env is
unset the @posthog/cli defaults to the US Cloud app host on its own,
which is correct for our project — so this PR doesn't need a new repo
variable for it.

---------

Co-authored-by: Nicholas-Xiong <2482929840@qq.com>
2026-05-21 11:48:57 +08:00

920 lines
38 KiB
YAML

name: release-stable
on:
workflow_dispatch:
inputs:
channel:
description: "Release channel. nightly publishes to R2 only; stable also creates a GitHub Release and tag."
required: true
type: choice
options:
- stable
- nightly
default: stable
nightly_version:
description: "Required when channel=stable: exact validated nightly version to promote, for example 0.5.1.nightly.3."
required: false
type: string
permissions:
actions: write
contents: write
concurrency:
group: open-design-release-stable-${{ inputs.channel }}
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 }}
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_NIGHTLY_METADATA_URL: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}/nightly/latest/metadata.json
OPEN_DESIGN_RELEASE_CHANNEL: ${{ inputs.channel }}
OPEN_DESIGN_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
OPEN_DESIGN_STABLE_NIGHTLY_VERSION: ${{ inputs.nightly_version }}
outputs:
base_version: ${{ steps.stable.outputs.base_version }}
branch: ${{ steps.stable.outputs.branch }}
channel: ${{ steps.stable.outputs.channel }}
commit: ${{ steps.stable.outputs.commit }}
github_release_enabled: ${{ steps.stable.outputs.github_release_enabled }}
nightly_number: ${{ steps.stable.outputs.nightly_number }}
previous_stable: ${{ steps.stable.outputs.previous_stable }}
release_name: ${{ steps.stable.outputs.release_name }}
release_version: ${{ steps.stable.outputs.release_version }}
stable_version: ${{ steps.stable.outputs.stable_version }}
state_source: ${{ steps.stable.outputs.state_source }}
version_tag: ${{ steps.stable.outputs.version_tag }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Prepare release metadata
id: stable
run: node --experimental-strip-types ./scripts/release-stable.ts
- name: Validate R2 release access
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: "true"
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
R2_ACCESS_PROBE_NAME: release-stable
RELEASE_CHANNEL: ${{ inputs.channel }}
run: bash .github/scripts/release/r2/check.sh
verify:
name: Verify build (typecheck + tests)
needs: metadata
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
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]
runs-on: macos-14
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
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: Verify 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"
for link in \
"$framework/Electron Framework" \
"$framework/Helpers" \
"$framework/Libraries" \
"$framework/Resources" \
"$framework/Versions/Current"; do
if [ ! -L "$link" ]; then
echo "Expected Electron framework symlink, got non-symlink: $link" >&2
ls -la "$framework" >&2 || true
ls -la "$framework/Versions" >&2 || true
exit 1
fi
done
- 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 release-stable
--portable
--app-version "${{ needs.metadata.outputs.release_version }}"
--mac-compression normal
--to all
--json
--signed
)
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 }}" = "nightly" ]; then
app_name="Open Design Nightly.app"
fi
built_framework="$RUNNER_TEMP/tools-pack/out/mac/namespaces/release-stable/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_NAMESPACE: release-stable
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:
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_NOTES: Open Design ${{ needs.metadata.outputs.release_version }}
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_NAMESPACE: release-stable
run: bash .github/scripts/release/assets/mac.sh
- name: Upload mac release bundle
uses: actions/upload-artifact@v7
with:
name: open-design-release-mac-release-assets
path: ${{ runner.temp }}/release-assets
build_mac_intel:
name: Build release mac intel x64
needs: [metadata, verify]
runs-on: macos-15-intel
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
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 release-stable-intel \
--portable \
--app-version "${{ needs.metadata.outputs.release_version }}" \
--mac-compression normal \
--to all \
--json \
--signed
- name: Prepare mac intel release assets
id: assets
env:
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_NAMESPACE: release-stable-intel
run: bash .github/scripts/release/assets/mac-intel.sh
- 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
build_win:
name: Build release win x64
needs: [metadata, verify]
runs-on: windows-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
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"
$buildArgs = @(
"exec", "tools-pack", "win", "build",
"--dir", $toolsPackDir,
"--cache-dir", $cacheDir,
"--namespace", "release-stable-win",
"--portable",
"--app-version", "${{ needs.metadata.outputs.release_version }}",
"--to", "nsis",
"--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
$buildOutput = pnpm exec tools-pack win build `
--dir $toolsPackDir `
--namespace release-stable-win `
--portable `
--app-version "${{ needs.metadata.outputs.release_version }}" `
--to nsis `
--json
if ($LASTEXITCODE -ne 0) {
throw "Windows tools-pack uncached fallback build exited with code $LASTEXITCODE"
}
}
$buildOutput | Set-Content -Path $buildJsonPath
$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: Smoke release windows packaged runtime
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_NAMESPACE: release-stable-win
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
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 = 3
$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
env:
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_NOTES: Open Design ${{ needs.metadata.outputs.release_version }}
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_NAMESPACE: release-stable-win
run: ./.github/scripts/release/assets/win.ps1
- name: Upload windows release bundle
uses: actions/upload-artifact@v7
with:
name: open-design-release-win-release-assets
path: ${{ runner.temp }}/release-assets
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: ${{ vars.ENABLE_STABLE_LINUX == 'true' }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
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 release-stable-linux
--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: release-stable-linux
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": "release-stable-linux",
"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_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_NAMESPACE: release-stable-linux
run: bash .github/scripts/release/assets/linux.sh
- name: Upload linux release bundle
uses: actions/upload-artifact@v7
with:
name: open-design-release-linux-release-assets
path: ${{ runner.temp }}/release-assets
publish:
name: Publish ${{ needs.metadata.outputs.channel }} release
needs:
- metadata
- verify
- build_mac
- build_mac_intel
- build_win
- build_linux
if: >-
${{
always() &&
!cancelled() &&
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
env:
GH_TOKEN: ${{ github.token }}
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: "true"
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
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"
NIGHTLY_NUMBER: ${{ needs.metadata.outputs.nightly_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:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- 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
- name: Download windows e2e spec report
uses: actions/download-artifact@v8
with:
name: open-design-release-win-e2e-report
path: ${{ runner.temp }}/release-report/win
- name: Write stable release notes
id: notes
if: ${{ needs.metadata.outputs.github_release_enabled == 'true' }}
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
all_release_dir="$RUNNER_TEMP/release-assets/all"
mkdir -p "$all_release_dir"
cp "$RUNNER_TEMP/release-assets/mac"/* "$all_release_dir/"
cp "$RUNNER_TEMP/release-assets/mac-intel"/* "$all_release_dir/"
cp "$RUNNER_TEMP/release-assets/win"/* "$all_release_dir/"
if [ "$ENABLE_LINUX" = "true" ]; then
cp "$RUNNER_TEMP/release-assets/linux"/* "$all_release_dir/"
fi
gh release upload "$VERSION_TAG" "$all_release_dir"/*
- name: Publish release assets and metadata to R2
id: r2
run: bash .github/scripts/release/r2/publish.sh
- name: Verify R2 release publish
env:
R2_LINUX_APPIMAGE_URL: ${{ steps.r2.outputs.linux_appimage_url }}
R2_MAC_DMG_URL: ${{ steps.r2.outputs.mac_dmg_url }}
R2_MAC_FEED_URL: ${{ steps.r2.outputs.mac_feed_url }}
R2_MAC_INTEL_DMG_URL: ${{ steps.r2.outputs.mac_intel_dmg_url }}
R2_MAC_INTEL_ZIP_URL: ${{ steps.r2.outputs.mac_intel_zip_url }}
R2_MAC_ZIP_URL: ${{ steps.r2.outputs.mac_zip_url }}
R2_METADATA_URL: ${{ steps.r2.outputs.metadata_url }}
R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }}
R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }}
R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }}
run: bash .github/scripts/release/r2/verify.sh
- 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:
R2_LINUX_APPIMAGE_URL: ${{ steps.r2.outputs.linux_appimage_url }}
R2_MAC_DMG_URL: ${{ steps.r2.outputs.mac_dmg_url }}
R2_MAC_FEED_URL: ${{ steps.r2.outputs.mac_feed_url }}
R2_MAC_INTEL_DMG_URL: ${{ steps.r2.outputs.mac_intel_dmg_url }}
R2_MAC_INTEL_ZIP_URL: ${{ steps.r2.outputs.mac_intel_zip_url }}
R2_MAC_ZIP_URL: ${{ steps.r2.outputs.mac_zip_url }}
R2_METADATA_URL: ${{ steps.r2.outputs.metadata_url }}
R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }}
R2_VERSION_METADATA_URL: ${{ steps.r2.outputs.version_metadata_url }}
R2_VERSION_PREFIX: ${{ steps.r2.outputs.version_prefix }}
R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }}
R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }}
run: bash .github/scripts/release/r2/summary.sh
- name: Cleanup workflow artifacts
if: ${{ success() }}
run: bash .github/scripts/release/github/cleanup-artifacts.sh