Add preview release channel

This commit is contained in:
PerishCode 2026-05-14 19:15:16 +08:00
parent 5b1e49ac8a
commit 43b1b94c8e
19 changed files with 1260 additions and 23 deletions

View file

@ -318,6 +318,15 @@ if (env.RELEASE_CHANNEL === "beta") {
betaVersion: env.RELEASE_VERSION,
...commonMetadata,
};
} else if (env.RELEASE_CHANNEL === "preview") {
metadata = {
assetVersionSuffix: env.ASSET_VERSION_SUFFIX ?? "",
baseVersion: env.BASE_VERSION,
previewNumber: Number(env.RELEASE_VERSION.split("-preview.")[1]),
previewVersion: env.RELEASE_VERSION,
releaseVersion: env.RELEASE_VERSION,
...commonMetadata,
};
} else {
metadata = {
baseVersion: env.BASE_VERSION,

View file

@ -61,6 +61,17 @@ if (metadata.channel === "beta") {
if (metadata.betaVersion !== process.env.EXPECTED_RELEASE_VERSION) {
throw new Error("unexpected metadata betaVersion: " + metadata.betaVersion);
}
} else if (metadata.channel === "preview") {
if (metadata.releaseVersion !== process.env.EXPECTED_RELEASE_VERSION) {
throw new Error("unexpected metadata releaseVersion: " + metadata.releaseVersion);
}
if (metadata.previewVersion !== process.env.EXPECTED_RELEASE_VERSION) {
throw new Error("unexpected metadata previewVersion: " + metadata.previewVersion);
}
const expectedPreviewNumber = Number(process.env.EXPECTED_RELEASE_VERSION.split("-preview.")[1]);
if (metadata.previewNumber !== expectedPreviewNumber) {
throw new Error("unexpected metadata previewNumber: " + metadata.previewNumber);
}
} else {
if (metadata.releaseVersion !== process.env.EXPECTED_RELEASE_VERSION) {
throw new Error("unexpected metadata releaseVersion: " + metadata.releaseVersion);

View file

@ -62,7 +62,7 @@ jobs:
required=true
fi
done
if [[ "$file" == "e2e/specs/mac.spec.ts" || "$file" == "e2e/specs/win.spec.ts" || "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/release-beta.yml" ]]; then
if [[ "$file" == "e2e/specs/mac.spec.ts" || "$file" == "e2e/specs/win.spec.ts" || "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/release-beta.yml" || "$file" == ".github/workflows/release-preview.yml" ]]; then
required=true
fi
if [ "$required" = "true" ]; then

View file

@ -189,7 +189,7 @@ jobs:
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-beta/builder/mac-arm64/Open Design.app/Contents/Frameworks/Electron Framework.framework"
built_framework="$RUNNER_TEMP/tools-pack/out/mac/namespaces/release-beta/builder/mac-arm64/Open Design Beta.app/Contents/Frameworks/Electron Framework.framework"
dump_framework() {
local label="$1"

View file

@ -4,18 +4,736 @@ on:
workflow_dispatch:
permissions:
actions: write
contents: read
concurrency:
group: open-design-release-preview
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 }}
jobs:
placeholder:
name: Preview release placeholder
metadata:
name: Prepare preview metadata
runs-on: ubuntu-latest
env:
OPEN_DESIGN_PREVIEW_METADATA_URL: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}/preview/latest/metadata.json
outputs:
base_version: ${{ steps.preview.outputs.base_version }}
branch: ${{ steps.preview.outputs.branch }}
channel: ${{ steps.preview.outputs.channel }}
commit: ${{ steps.preview.outputs.commit }}
github_release_enabled: ${{ steps.preview.outputs.github_release_enabled }}
latest_stable: ${{ steps.preview.outputs.latest_stable }}
preview_number: ${{ steps.preview.outputs.preview_number }}
preview_version: ${{ steps.preview.outputs.preview_version }}
release_name: ${{ steps.preview.outputs.release_name }}
release_version: ${{ steps.preview.outputs.release_version }}
state_source: ${{ steps.preview.outputs.state_source }}
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 preview release metadata
id: preview
run: node --experimental-strip-types ./scripts/release-preview.ts
- name: Validate R2 preview 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-preview
RELEASE_CHANNEL: preview
run: bash .github/scripts/release/r2/check.sh
verify:
name: Verify build (typecheck + tests)
needs: metadata
runs-on: ubuntu-latest
steps:
- name: Explain placeholder
- 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: Build daemon and desktop (typecheck dependencies)
run: |
echo "release-preview is reserved for the preview release channel."
echo "The full build and publish implementation will land in a follow-up change."
pnpm --filter @open-design/daemon build
pnpm --filter @open-design/desktop build
- name: Typecheck workspaces
run: pnpm -r --workspace-concurrency=1 --if-present run typecheck
- name: Check repository layout policies
run: pnpm guard
build_mac:
name: Build preview 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 preview 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-preview
--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)"
built_framework="$RUNNER_TEMP/tools-pack/out/mac/namespaces/release-preview/builder/mac-arm64/Open Design Preview.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-preview-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 preview 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-preview
OD_PACKAGED_E2E_RELEASE_CHANNEL: preview
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-preview-mac-e2e-report
path: ${{ runner.temp }}/release-report/mac
if-no-files-found: warn
- name: Prepare mac preview assets
env:
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_CHANNEL: preview
RELEASE_NOTES: Open Design Preview ${{ needs.metadata.outputs.release_version }}
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_NAMESPACE: release-preview
run: bash .github/scripts/release/assets/mac.sh
- name: Upload mac preview bundle
uses: actions/upload-artifact@v7
with:
name: open-design-preview-mac-release-assets
path: ${{ runner.temp }}/release-assets
build_mac_intel:
name: Build preview 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 preview 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-preview-intel \
--portable \
--app-version "${{ needs.metadata.outputs.release_version }}" \
--mac-compression normal \
--to all \
--json \
--signed
- name: Prepare mac intel preview assets
env:
RELEASE_CHANNEL: preview
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_NAMESPACE: release-preview-intel
run: bash .github/scripts/release/assets/mac-intel.sh
- name: Upload mac intel preview bundle
uses: actions/upload-artifact@v7
with:
name: open-design-preview-mac-intel-release-assets
path: ${{ runner.temp }}/release-assets
build_win:
name: Build preview 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/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-preview.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-preview-$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 preview 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-preview-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-preview-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 preview 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-preview-win
OD_PACKAGED_E2E_RELEASE_CHANNEL: preview
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-preview-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 preview assets
shell: pwsh
env:
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_CHANNEL: preview
RELEASE_NOTES: Open Design Preview ${{ needs.metadata.outputs.release_version }}
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_NAMESPACE: release-preview-win
run: ./.github/scripts/release/assets/win.ps1
- name: Upload windows preview bundle
uses: actions/upload-artifact@v7
with:
name: open-design-preview-win-release-assets
path: ${{ runner.temp }}/release-assets
build_linux:
name: Build preview linux x64
needs: [metadata, verify]
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
- name: Build preview linux artifacts
run: |
set -euo pipefail
pnpm exec tools-pack linux build \
--dir "$RUNNER_TEMP/tools-pack" \
--namespace release-preview-linux \
--portable \
--app-version "${{ needs.metadata.outputs.release_version }}" \
--to appimage \
--containerized \
--json
- name: Prepare linux preview assets
env:
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_NAMESPACE: release-preview-linux
run: bash .github/scripts/release/assets/linux.sh
- name: Upload linux preview bundle
uses: actions/upload-artifact@v7
with:
name: open-design-preview-linux-release-assets
path: ${{ runner.temp }}/release-assets
publish:
name: Publish preview 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: "false"
MAC_INTEL_SIGNED: "true"
RELEASE_CHANNEL: preview
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 }}
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: Download mac preview bundle
uses: actions/download-artifact@v8
with:
name: open-design-preview-mac-release-assets
path: ${{ runner.temp }}/release-assets/mac
- name: Download mac intel preview bundle
uses: actions/download-artifact@v8
with:
name: open-design-preview-mac-intel-release-assets
path: ${{ runner.temp }}/release-assets/mac-intel
- name: Download windows preview bundle
uses: actions/download-artifact@v8
with:
name: open-design-preview-win-release-assets
path: ${{ runner.temp }}/release-assets/win
- name: Download linux preview bundle
if: ${{ needs.build_linux.result == 'success' }}
uses: actions/download-artifact@v8
with:
name: open-design-preview-linux-release-assets
path: ${{ runner.temp }}/release-assets/linux
- name: Download mac e2e spec report
uses: actions/download-artifact@v8
with:
name: open-design-preview-mac-e2e-report
path: ${{ runner.temp }}/release-report/mac
- name: Download windows e2e spec report
uses: actions/download-artifact@v8
with:
name: open-design-preview-win-e2e-report
path: ${{ runner.temp }}/release-report/win
- name: Publish preview assets and metadata to R2
id: r2
run: bash .github/scripts/release/r2/publish.sh
- name: Verify R2 preview 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: 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

View file

@ -51,6 +51,14 @@ This file is the single source of truth for agents entering this repository. Rea
- Do not add root aggregate `pnpm build` or `pnpm test` aliases. Build/test commands must stay package-scoped (`pnpm --filter <package> ...`) or tool-scoped (`pnpm tools-pack ...` / `pnpm tools-pr ...`).
- Do not add root e2e aliases; e2e package commands and ownership rules live in `e2e/AGENTS.md`.
## Release channel model
- `beta` is the daily R&D/development validation channel. It is optimized for fast development feedback and is not part of the stable promotion gate.
- `nightly` is the internal validation channel for stable delivery. Stable releases remain gated by validated nightly artifacts.
- `preview` is an independent early-access channel with stable-like release rigor. It should use preview versions such as `X.Y.Z-preview.N`, publish to the `preview` R2 channel, publish updater feeds under `preview/latest`, and follow stable's platform policy including the existing optional Linux enablement.
- `stable` is the formal delivery channel. Do not make stable promotion depend on preview; stable continues to depend on nightly only.
- Public packaged app identity must stay channel-distinct: stable uses `Open Design`, beta uses `Open Design Beta`, and preview uses `Open Design Preview`. Do not ship beta or preview mac DMGs whose drag-install app bundle is `Open Design.app`.
## Boundary constraints
- Tests under `apps/`, `packages/`, and `tools/` live in a package/app/tool-level `tests/` directory sibling to `src/`; keep `src/` source-only and do not add new `*.test.ts` or `*.test.tsx` files under `src/`. Playwright UI automation belongs to `e2e/ui/`, not app packages.

View file

@ -1,6 +1,6 @@
{
"name": "@open-design/packaged",
"version": "0.7.1",
"version": "0.8.0",
"private": true,
"type": "module",
"main": "./dist/index.mjs",

View file

@ -483,6 +483,8 @@ function resolveInstallIdentity(value: string): { displayName: string; namespace
const namespaceToken = value.replace(/[^A-Za-z0-9._-]+/g, '-');
const displayName = /(^|[-_.])beta($|[-_.])/i.test(value)
? 'Open Design Beta'
: /(^|[-_.])preview($|[-_.])/i.test(value)
? 'Open Design Preview'
: value === 'default'
? 'Open Design'
: `Open Design ${namespaceToken}`;

327
scripts/release-preview.ts Normal file
View file

@ -0,0 +1,327 @@
import { execFile as execFileCallback } from "node:child_process";
import { appendFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { get as httpsGet } from "node:https";
import { join } from "node:path";
import { promisify } from "node:util";
const execFile = promisify(execFileCallback);
const stableVersionPattern = /^(\d+)\.(\d+)\.(\d+)$/;
const stableTagPattern = /^open-design-v(\d+\.\d+\.\d+)$/;
const previewReleaseBranchPattern = /^preview\/v(\d+\.\d+\.\d+)$/;
const previewVersionPattern = /^(\d+\.\d+\.\d+)-preview\.(\d+)$/;
type ParsedStableVersion = {
parsed: [number, number, number];
value: string;
};
type ParsedPreviewVersion = {
baseVersion: string;
previewNumber: number;
previewVersion: string;
};
type ParsedPreviewMetadata = ParsedPreviewVersion & {
source: "metadata-json";
};
function fail(message: string): never {
console.error(`[release-preview] ${message}`);
process.exit(1);
}
function parseStableVersion(value: string): [number, number, number] | null {
const match = stableVersionPattern.exec(value);
if (match == null) return null;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function compareVersions(left: [number, number, number], right: [number, number, number]): number {
const [leftMajor, leftMinor, leftPatch] = left;
const [rightMajor, rightMinor, rightPatch] = right;
const pairs = [
[leftMajor, rightMajor],
[leftMinor, rightMinor],
[leftPatch, rightPatch],
] as const;
for (const [leftPart, rightPart] of pairs) {
if (leftPart > rightPart) return 1;
if (leftPart < rightPart) return -1;
}
return 0;
}
function extractStableVersionFromTag(tag: string): ParsedStableVersion | null {
const match = stableTagPattern.exec(tag);
if (match?.[1] == null) return null;
const parsed = parseStableVersion(match[1]);
return parsed == null ? null : { parsed, value: match[1] };
}
function parsePreviewParts(baseVersion: string, previewNumber: string): ParsedPreviewVersion {
const parsedPreviewNumber = Number(previewNumber);
if (!Number.isSafeInteger(parsedPreviewNumber) || parsedPreviewNumber < 1) {
fail(`invalid preview number in latest preview metadata: ${previewNumber}`);
}
return {
baseVersion,
previewNumber: parsedPreviewNumber,
previewVersion: `${baseVersion}-preview.${previewNumber}`,
};
}
function readStringField(record: Record<string, unknown>, field: string): string | null {
const value = record[field];
return typeof value === "string" && value.length > 0 ? value : null;
}
function readNumberField(record: Record<string, unknown>, field: string): number | null {
const value = record[field];
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
}
function parsePreviewVersion(value: string, sourceName: string): ParsedPreviewVersion {
const match = previewVersionPattern.exec(value);
if (match?.[1] == null || match[2] == null) {
fail(`${sourceName} previewVersion must be x.y.z-preview.N; got ${value}`);
}
return parsePreviewParts(match[1], match[2]);
}
function parsePreviewMetadataJson(value: string): ParsedPreviewMetadata {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch (error) {
fail(`R2 preview metadata.json is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
}
if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) {
fail("R2 preview metadata.json must be a JSON object");
}
const record = parsed as Record<string, unknown>;
const previewVersion = readStringField(record, "previewVersion") ?? readStringField(record, "releaseVersion");
const previewNumber = readNumberField(record, "previewNumber");
const baseVersion = readStringField(record, "baseVersion");
if (previewVersion != null) {
const preview = parsePreviewVersion(previewVersion, "R2 preview metadata.json");
if (baseVersion != null && baseVersion !== preview.baseVersion) {
fail(`R2 preview metadata.json baseVersion ${baseVersion} does not match previewVersion ${preview.previewVersion}`);
}
if (previewNumber != null && previewNumber !== preview.previewNumber) {
fail(`R2 preview metadata.json previewNumber ${previewNumber} does not match previewVersion ${preview.previewVersion}`);
}
return { ...preview, source: "metadata-json" };
}
if (baseVersion == null || previewNumber == null) {
fail("R2 preview metadata.json must include previewVersion or baseVersion+previewNumber");
}
const parsedBase = parseStableVersion(baseVersion);
if (parsedBase == null) {
fail(`R2 preview metadata.json baseVersion must be x.y.z; got ${baseVersion}`);
}
return { ...parsePreviewParts(baseVersion, String(previewNumber)), source: "metadata-json" };
}
async function readPackagedVersion(): Promise<string> {
const packageJsonPath = join(process.cwd(), "apps", "packaged", "package.json");
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: unknown };
if (typeof packageJson.version !== "string") {
fail(`missing version in ${packageJsonPath}`);
}
if (!stableVersionPattern.test(packageJson.version)) {
fail(`apps/packaged/package.json version must be a stable x.y.z base version; got ${packageJson.version}`);
}
return packageJson.version;
}
async function fetchGitTags(pattern: string): Promise<string[]> {
const { stdout } = await execFile("git", ["tag", "--list", pattern]);
return stdout
.split("\n")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
}
function fetchOptionalHttpsText(url: string, redirectCount = 0): Promise<string | null> {
return new Promise((resolvePromise, reject) => {
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
reject(new Error(`expected HTTPS URL for preview feed lookup: ${parsed.protocol}`));
return;
}
const request = httpsGet(
parsed,
{
headers: {
"Cache-Control": "no-cache",
},
},
(response) => {
const statusCode = response.statusCode ?? 0;
if (statusCode === 404) {
response.resume();
resolvePromise(null);
return;
}
const location = response.headers.location;
if (statusCode >= 300 && statusCode < 400 && typeof location === "string") {
response.resume();
if (redirectCount >= 3) {
reject(new Error("too many redirects while reading preview feed"));
return;
}
const nextUrl = new URL(location, parsed).toString();
fetchOptionalHttpsText(nextUrl, redirectCount + 1).then(resolvePromise, reject);
return;
}
if (statusCode < 200 || statusCode >= 300) {
response.resume();
reject(new Error(`preview feed request failed with HTTP ${statusCode}`));
return;
}
const chunks: Buffer[] = [];
response.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
response.on("end", () => {
resolvePromise(Buffer.concat(chunks).toString("utf8"));
});
},
);
request.setTimeout(10_000, () => {
request.destroy(new Error("timed out while reading preview feed"));
});
request.on("error", reject);
});
}
function validateHttpsUrl(value: string, name: string): void {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
fail(`${name} must be an HTTPS URL; got ${value}`);
}
if (parsed.protocol !== "https:") {
fail(`${name} must be an HTTPS URL; got ${value}`);
}
}
function setOutput(name: string, value: string): void {
const outputPath = process.env.GITHUB_OUTPUT;
if (outputPath == null || outputPath.length === 0) return;
appendFileSync(outputPath, `${name}=${value}\n`);
}
const packagedVersion = await readPackagedVersion();
const packagedParsed = parseStableVersion(packagedVersion) ?? fail(`invalid packaged version: ${packagedVersion}`);
const branch = process.env.GITHUB_REF_NAME ?? "";
const branchMatch = previewReleaseBranchPattern.exec(branch);
if (branchMatch?.[1] == null) {
fail(`release-preview can only run from preview/vX.Y.Z branches; got ${branch || "(empty)"}`);
}
const branchVersion = branchMatch[1];
if (branchVersion !== packagedVersion) {
fail(`preview branch version ${branchVersion} must match apps/packaged/package.json version ${packagedVersion}`);
}
const tags = await fetchGitTags("open-design-v*");
let latestStable: ParsedStableVersion | null = null;
for (const tag of tags) {
const stableVersion = extractStableVersionFromTag(tag);
if (stableVersion == null) continue;
if (latestStable == null || compareVersions(stableVersion.parsed, latestStable.parsed) > 0) {
latestStable = stableVersion;
}
}
if (latestStable != null && compareVersions(packagedParsed, latestStable.parsed) <= 0) {
fail(`packaged base version ${packagedVersion} must be strictly greater than latest stable ${latestStable.value}`);
}
const metadataUrl = process.env.OPEN_DESIGN_PREVIEW_METADATA_URL;
if (metadataUrl == null || metadataUrl.length === 0) {
fail("OPEN_DESIGN_PREVIEW_METADATA_URL is required");
}
validateHttpsUrl(metadataUrl, "OPEN_DESIGN_PREVIEW_METADATA_URL");
let previewNumber = 1;
let latestPreview: ParsedPreviewVersion | null = null;
let stateSource = "R2 metadata.json";
const latestMetadataJson = await fetchOptionalHttpsText(metadataUrl);
if (latestMetadataJson == null) {
latestPreview = {
baseVersion: packagedVersion,
previewNumber: 0,
previewVersion: `${packagedVersion}-preview.0`,
};
stateSource = "missing R2 metadata.json fallback preview.0";
console.log("[release-preview] R2 preview metadata.json: not found; using preview.0 fallback");
} else {
latestPreview = parsePreviewMetadataJson(latestMetadataJson);
console.log(`[release-preview] R2 preview metadata.json version: ${latestPreview.previewVersion}`);
}
if (latestPreview != null) {
const preview = latestPreview;
const existingBase = parseStableVersion(preview.baseVersion);
if (existingBase == null) {
fail(`invalid preview base version in ${stateSource}: ${preview.baseVersion}`);
}
const ordering = compareVersions(packagedParsed, existingBase);
if (ordering < 0) {
fail(`packaged base version ${packagedVersion} regressed below current preview base version ${preview.baseVersion}`);
}
if (ordering === 0) {
previewNumber = preview.previewNumber + 1;
}
}
const previewVersion = `${packagedVersion}-preview.${previewNumber}`;
const commit = process.env.GITHUB_SHA ?? "";
const releaseName = `Open Design Preview ${previewVersion}`;
console.log("[release-preview] channel: preview");
console.log(`[release-preview] base version: ${packagedVersion}`);
console.log(`[release-preview] preview version: ${previewVersion}`);
console.log(`[release-preview] preview state source: ${stateSource}`);
if (latestStable != null) console.log(`[release-preview] latest stable: ${latestStable.value}`);
if (latestPreview != null) console.log(`[release-preview] latest preview: ${latestPreview.previewVersion}`);
setOutput("asset_version_suffix", "");
setOutput("base_version", packagedVersion);
setOutput("branch", branch);
setOutput("channel", "preview");
setOutput("commit", commit);
setOutput("github_release_enabled", "false");
setOutput("latest_stable", latestStable?.value ?? "");
setOutput("preview_number", String(previewNumber));
setOutput("preview_version", previewVersion);
setOutput("release_name", releaseName);
setOutput("release_version", previewVersion);
setOutput("state_source", stateSource);

View file

@ -25,7 +25,7 @@ Follow the root `AGENTS.md` and `tools/AGENTS.md` first. This tool owns the repo
- Do not hand-build `--od-stamp-*` args; use `createProcessStampArgs` with `OPEN_DESIGN_SIDECAR_CONTRACT`.
- Do not use port numbers in data/log/runtime/cache path decisions. Namespace decides paths; ports are only transient transports.
- Release artifacts keep canonical app identity (`Open Design.app` on mac, `Open Design.exe` inside the Windows installer); local tools-pack installs may use namespace-scoped install paths only as a developer multi-instance validation convention.
- Public release artifacts must use channel-specific app identity: stable uses `Open Design.app`, beta uses `Open Design Beta.app`, and preview uses `Open Design Preview.app` on mac. Local tools-pack installs may still use namespace-scoped install paths only as a developer multi-instance validation convention.
- Do not let namespace-named `.app` installs change data/log/runtime/cache path conventions.
- Use `--portable` for public/release artifacts so packaged config does not bake local tools-pack runtime roots from the build machine.
- Pack resource files used by electron-builder belong under `tools/pack/resources/`; do not point pack logic at Downloads, web public assets, docs assets, or other app-owned resource paths.

View file

@ -17,7 +17,8 @@ import {
} from "../mac-prebundle.js";
import { copyBundledResourceTrees } from "../resources.js";
import { runEsbuild, runNpmInstall, runPnpm } from "./commands.js";
import { INTERNAL_PACKAGES, PRODUCT_NAME } from "./constants.js";
import { INTERNAL_PACKAGES } from "./constants.js";
import { resolveMacInstallIdentity } from "./identity.js";
import { readPackagedVersion } from "./manifest.js";
import type { MacPaths, PackedTarballInfo } from "./types.js";
@ -176,6 +177,7 @@ export async function writeAssembledApp(
packedTarballs: PackedTarballInfo[],
): Promise<void> {
const packagedVersion = await readPackagedVersion(config);
const identity = resolveMacInstallIdentity(config);
await rm(join(config.roots.output.namespaceRoot, "assembled"), { force: true, recursive: true });
await mkdir(paths.assembledAppRoot, { recursive: true });
const tarballByPackage = Object.fromEntries(
@ -208,7 +210,7 @@ export async function writeAssembledApp(
main: "./main.cjs",
name: "open-design-packaged-app",
private: true,
productName: PRODUCT_NAME,
productName: identity.productName,
version: packagedVersion,
},
null,

View file

@ -13,6 +13,7 @@ import {
WEB_STANDALONE_RESOURCE_NAME,
} from "./constants.js";
import { pathExists } from "./fs.js";
import { resolveMacInstallIdentity } from "./identity.js";
import { readPackagedVersion } from "./manifest.js";
import { sanitizeNamespace } from "./paths.js";
import type { ElectronBuilderTarget, MacBuildOutput, MacPaths } from "./types.js";
@ -80,12 +81,13 @@ export async function runElectronBuilder(
targets: ElectronBuilderTarget[],
): Promise<void> {
const namespaceToken = sanitizeNamespace(config.namespace);
const identity = resolveMacInstallIdentity(config);
const packagedVersion = await readPackagedVersion(config);
const webStandaloneHookConfigPath = config.webOutputMode === "standalone"
? await writeWebStandaloneHookConfig(config, paths)
: null;
const builderConfig = {
appId: "io.open-design.desktop",
appId: identity.appId,
artifactName: `${PRODUCT_NAME}-${namespaceToken}.\${ext}`,
afterPack: webStandaloneHookConfigPath == null ? undefined : macResources.webStandaloneAfterPackHook,
afterSign: config.signed ? macResources.notarizeHook : undefined,
@ -98,7 +100,7 @@ export async function runElectronBuilder(
dmg: {
icon: macResources.icon,
iconSize: 96,
title: `${PRODUCT_NAME}-${namespaceToken}`,
title: `${identity.productName}-${namespaceToken}`,
},
electronDist: config.electronDistPath,
electronVersion: config.electronVersion,
@ -106,7 +108,7 @@ export async function runElectronBuilder(
extraMetadata: {
main: "./main.cjs",
name: "open-design-packaged-app",
productName: PRODUCT_NAME,
productName: identity.productName,
version: packagedVersion,
},
extraResources: [
@ -128,7 +130,7 @@ export async function runElectronBuilder(
},
nodeGypRebuild: false,
npmRebuild: false,
productName: PRODUCT_NAME,
productName: identity.productName,
icon: macResources.icon,
publish: [
{

View file

@ -0,0 +1,42 @@
import { SIDECAR_DEFAULTS } from "@open-design/sidecar-proto";
import type { ToolPackConfig } from "../config.js";
import { PRODUCT_NAME } from "./constants.js";
export type MacInstallIdentity = {
appId: string;
productName: string;
publicAppBundleName: string;
systemAppBundleName: string;
};
function isChannelNamespace(namespace: string, channel: "beta" | "preview"): boolean {
return new RegExp(`(^|[-_.])${channel}($|[-_.])`, "i").test(namespace);
}
function sanitizeNamespace(value: string): string {
return value.replace(/[^A-Za-z0-9._-]+/g, "-");
}
export function resolveMacInstallIdentity(config: Pick<ToolPackConfig, "namespace">): MacInstallIdentity {
const namespaceToken = sanitizeNamespace(config.namespace);
const channelIdentity = isChannelNamespace(config.namespace, "beta")
? { appId: "io.open-design.desktop.beta", productName: `${PRODUCT_NAME} Beta` }
: isChannelNamespace(config.namespace, "preview")
? { appId: "io.open-design.desktop.preview", productName: `${PRODUCT_NAME} Preview` }
: { appId: "io.open-design.desktop", productName: PRODUCT_NAME };
const publicAppBundleName = `${channelIdentity.productName}.app`;
const systemAppBundleName = (
config.namespace === SIDECAR_DEFAULTS.namespace ||
isChannelNamespace(config.namespace, "beta") ||
isChannelNamespace(config.namespace, "preview")
)
? publicAppBundleName
: `${PRODUCT_NAME}.${namespaceToken}.app`;
return {
...channelIdentity,
publicAppBundleName,
systemAppBundleName,
};
}

View file

@ -1,5 +1,6 @@
export { packMac } from "./build.js";
export { PACKAGED_CONFIG_PATH_ENV, resolveSeededAppConfigPaths, seedPackagedAppConfig, writeLaunchPackagedConfig } from "./app-config.js";
export { resolveMacInstallIdentity } from "./identity.js";
export {
cleanupPackedMacNamespace,
installPackedMacDmg,
@ -21,3 +22,4 @@ export type {
MacStopResult,
MacUninstallResult,
} from "./types.js";
export type { MacInstallIdentity } from "./identity.js";

View file

@ -27,8 +27,9 @@ import {
} from "@open-design/platform";
import type { ToolPackConfig } from "../config.js";
import { PACKAGED_CONFIG_PATH_ENV, writeLaunchPackagedConfig } from "./app-config.js";
import { DESKTOP_LOG_ECHO_ENV, PRODUCT_NAME } from "./constants.js";
import { DESKTOP_LOG_ECHO_ENV } from "./constants.js";
import { clearQuarantine, pathExists } from "./fs.js";
import { resolveMacInstallIdentity } from "./identity.js";
import { desktopIdentityPath, desktopLogPath, macAppExecutablePath, resolveMacPaths } from "./paths.js";
import type { DesktopRootIdentityFallback, DesktopRootIdentityMarker, MacCleanupResult, MacInspectResult, MacInstallResult, MacStartResult, MacStartSource, MacStopResult, MacUninstallResult } from "./types.js";
@ -480,6 +481,7 @@ async function detachMount(mountPoint: string): Promise<boolean> {
export async function installPackedMacDmg(config: ToolPackConfig): Promise<MacInstallResult> {
const paths = resolveMacPaths(config);
const identity = resolveMacInstallIdentity(config);
if (!(await pathExists(paths.dmgPath))) {
throw new Error(`no mac dmg found at ${paths.dmgPath}; run tools-pack mac build --to all first`);
}
@ -499,7 +501,7 @@ export async function installPackedMacDmg(config: ToolPackConfig): Promise<MacIn
"-nobrowse",
"-quiet",
]);
await execFileAsync("ditto", [join(paths.mountPoint, `${PRODUCT_NAME}.app`), paths.installedAppPath]);
await execFileAsync("ditto", [join(paths.mountPoint, identity.publicAppBundleName), paths.installedAppPath]);
await clearQuarantine(paths.installedAppPath);
} finally {
detached = await detachMount(paths.mountPoint);

View file

@ -14,6 +14,7 @@ import {
MAC_PREBUNDLED_PACKAGED_MAIN_RELATIVE_PATH,
MAC_PREBUNDLED_WEB_SIDECAR_RELATIVE_PATH,
} from "../mac-prebundle.js";
import { resolveMacInstallIdentity } from "./identity.js";
import type { MacPaths } from "./types.js";
export function sanitizeNamespace(value: string): string {
@ -36,10 +37,11 @@ export function resolveMacPaths(config: ToolPackConfig): MacPaths {
const namespaceRoot = config.roots.output.namespaceRoot;
const appBuilderOutputRoot = config.roots.output.appBuilderRoot;
const namespaceToken = sanitizeNamespace(config.namespace);
const identity = resolveMacInstallIdentity(config);
const appPath = join(
appBuilderOutputRoot,
resolveMacAppOutputDirectoryName(),
`${PRODUCT_NAME}.app`,
identity.publicAppBundleName,
);
const installApplicationsRoot = join(namespaceRoot, "install", "Applications");
const installedAppPath = join(installApplicationsRoot, macAppBundleName(config.namespace));
@ -67,9 +69,9 @@ export function resolveMacPaths(config: ToolPackConfig): MacPaths {
packagedMainPrebundlePath: join(namespaceRoot, "assembled", MAC_PREBUNDLED_PACKAGED_MAIN_RELATIVE_PATH),
packagedConfigPath: join(namespaceRoot, "open-design-config.json"),
resourceRoot: join(namespaceRoot, "resources", "open-design"),
systemApplicationsAppPath: join("/Applications", macAppBundleName(config.namespace)),
systemApplicationsAppPath: join("/Applications", identity.systemAppBundleName),
tarballsRoot: join(namespaceRoot, "tarballs"),
userApplicationsAppPath: join(homedir(), "Applications", macAppBundleName(config.namespace)),
userApplicationsAppPath: join(homedir(), "Applications", identity.systemAppBundleName),
webStandaloneHookAuditPath: join(namespaceRoot, "web-standalone-after-pack-audit.json"),
webStandaloneHookConfigPath: join(namespaceRoot, "web-standalone-after-pack-config.json"),
webSidecarPrebundleMetaPath: join(namespaceRoot, MAC_PREBUNDLE_META_DIR_NAME, "web-sidecar.meta.json"),

View file

@ -12,8 +12,8 @@ export type WinInstallIdentity = {
uninstallerName: string;
};
function isBetaNamespace(namespace: string): boolean {
return /(^|[-_.])beta($|[-_.])/i.test(namespace);
function isChannelNamespace(namespace: string, channel: "beta" | "preview"): boolean {
return new RegExp(`(^|[-_.])${channel}($|[-_.])`, "i").test(namespace);
}
function sanitizeNamespace(value: string): string {
@ -22,8 +22,10 @@ function sanitizeNamespace(value: string): string {
export function resolveWinInstallIdentity(config: Pick<ToolPackConfig, "namespace">): WinInstallIdentity {
const namespaceToken = sanitizeNamespace(config.namespace);
const displayName = isBetaNamespace(config.namespace)
const displayName = isChannelNamespace(config.namespace, "beta")
? `${PRODUCT_NAME} Beta`
: isChannelNamespace(config.namespace, "preview")
? `${PRODUCT_NAME} Preview`
: config.namespace === SIDECAR_DEFAULTS.namespace
? PRODUCT_NAME
: `${PRODUCT_NAME} ${namespaceToken}`;

View file

@ -0,0 +1,77 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { ToolPackConfig } from "../src/config.js";
import { resolveMacInstallIdentity } from "../src/mac/identity.js";
import { resolveMacPaths } from "../src/mac/paths.js";
function makeConfig(root: string, namespace: string): ToolPackConfig {
return {
containerized: false,
electronBuilderCliPath: "/x/electron-builder/cli.js",
electronDistPath: "/x/electron/dist",
electronVersion: "41.3.0",
macCompression: "normal",
namespace,
platform: "mac",
portable: true,
removeData: false,
removeLogs: false,
removeProductUserData: false,
removeSidecars: false,
roots: {
output: {
appBuilderRoot: join(root, ".tmp", "tools-pack", "out", "mac", "namespaces", namespace, "builder"),
namespaceRoot: join(root, ".tmp", "tools-pack", "out", "mac", "namespaces", namespace),
platformRoot: join(root, ".tmp", "tools-pack", "out", "mac"),
root: join(root, ".tmp", "tools-pack", "out"),
},
runtime: {
namespaceBaseRoot: join(root, ".tmp", "tools-pack", "runtime", "mac", "namespaces"),
namespaceRoot: join(root, ".tmp", "tools-pack", "runtime", "mac", "namespaces", namespace),
},
cacheRoot: join(root, ".tmp", "tools-pack", "cache"),
toolPackRoot: join(root, ".tmp", "tools-pack"),
},
signed: false,
silent: true,
to: "dmg",
webOutputMode: "standalone",
workspaceRoot: root,
};
}
describe("resolveMacInstallIdentity", () => {
it("keeps stable builds on the canonical mac identity", () => {
expect(resolveMacInstallIdentity(makeConfig("/work", "release-stable"))).toMatchObject({
appId: "io.open-design.desktop",
productName: "Open Design",
publicAppBundleName: "Open Design.app",
});
});
it("uses first-class beta app identity for beta release namespaces", () => {
const config = makeConfig("/work", "release-beta");
expect(resolveMacInstallIdentity(config)).toEqual({
appId: "io.open-design.desktop.beta",
productName: "Open Design Beta",
publicAppBundleName: "Open Design Beta.app",
systemAppBundleName: "Open Design Beta.app",
});
expect(resolveMacPaths(config).appPath).toMatch(/Open Design Beta\.app$/);
});
it("uses first-class preview app identity for preview release namespaces", () => {
const config = makeConfig("/work", "release-preview");
expect(resolveMacInstallIdentity(config)).toEqual({
appId: "io.open-design.desktop.preview",
productName: "Open Design Preview",
publicAppBundleName: "Open Design Preview.app",
systemAppBundleName: "Open Design Preview.app",
});
expect(resolveMacPaths(config).appPath).toMatch(/Open Design Preview\.app$/);
});
});

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { resolveWinInstallIdentity } from "../src/win/identity.js";
describe("resolveWinInstallIdentity", () => {
it("keeps the default namespace on the canonical Windows display name", () => {
expect(resolveWinInstallIdentity({ namespace: "default" })).toMatchObject({
displayName: "Open Design",
shortcutName: "Open Design.lnk",
uninstallerName: "Uninstall Open Design.exe",
});
});
it("uses first-class beta display identity for beta release namespaces", () => {
expect(resolveWinInstallIdentity({ namespace: "release-beta-win" })).toMatchObject({
appPathsKey: "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\Open Design Beta.exe",
displayName: "Open Design Beta",
shortcutName: "Open Design Beta.lnk",
uninstallerName: "Uninstall Open Design Beta.exe",
});
});
it("uses first-class preview display identity for preview release namespaces", () => {
expect(resolveWinInstallIdentity({ namespace: "release-preview-win" })).toMatchObject({
appPathsKey: "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\Open Design Preview.exe",
displayName: "Open Design Preview",
shortcutName: "Open Design Preview.lnk",
uninstallerName: "Uninstall Open Design Preview.exe",
});
});
});