From 23afddfbf90e961f9e079042d5612dc7724b057f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 1 Mar 2026 16:28:51 -0800 Subject: [PATCH] Add tagged desktop release pipeline and unified artifact builder - add GitHub Actions release workflow for v*.*.* tags with preflight checks - build and publish macOS, Linux, and Windows desktop artifacts in one release - replace DMG-only script with cross-platform `dist:desktop:artifact` flow - document release, signing setup, and new desktop dist commands --- .github/workflows/release.yml | 180 ++++++++ README.md | 12 + docs/release.md | 105 +++++ package.json | 9 +- scripts/build-desktop-artifact.mjs | 672 +++++++++++++++++++++++++++++ scripts/build-desktop-dmg.mjs | 389 +---------------- 6 files changed, 978 insertions(+), 389 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/release.md create mode 100644 scripts/build-desktop-artifact.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000000000000000000000000000000000..6bebd0c6252f7e6b1414368eb469fa933878e8a4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,180 @@ +name: Release Desktop + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +jobs: + preflight: + name: Preflight + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.version.outputs.value }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: package.json + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Lint + run: bun run lint + + - name: Typecheck + run: bun run typecheck + + - name: Test + run: bun run test + + - id: version + name: Resolve release version + run: echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.label }} + needs: preflight + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - label: macOS arm64 + runner: macos-14 + platform: mac + target: dmg + arch: arm64 + - label: macOS x64 + runner: macos-13 + platform: mac + target: dmg + arch: x64 + - label: Linux x64 + runner: ubuntu-24.04 + platform: linux + target: AppImage + arch: x64 + - label: Windows x64 + runner: windows-2022 + platform: win + target: nsis + arch: x64 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: package.json + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build desktop artifact + shell: bash + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + run: | + args=( + --platform "${{ matrix.platform }}" + --target "${{ matrix.target }}" + --arch "${{ matrix.arch }}" + --version "${{ needs.preflight.outputs.version }}" + ) + + has_all() { + for value in "$@"; do + if [[ -z "$value" ]]; then + return 1 + fi + done + return 0 + } + + if [[ "${{ matrix.platform }}" == "mac" ]]; then + if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then + key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + echo "macOS signing enabled." + args+=(--signed) + else + echo "macOS signing disabled (missing one or more Apple signing secrets)." + fi + elif [[ "${{ matrix.platform }}" == "win" ]]; then + if has_all \ + "$AZURE_TENANT_ID" \ + "$AZURE_CLIENT_ID" \ + "$AZURE_CLIENT_SECRET" \ + "$AZURE_TRUSTED_SIGNING_ENDPOINT" \ + "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" \ + "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \ + "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then + echo "Windows signing enabled (Azure Trusted Signing)." + args+=(--signed) + else + echo "Windows signing disabled (missing one or more Azure Trusted Signing secrets)." + fi + else + echo "Signing disabled for ${{ matrix.platform }}." + fi + + bun run dist:desktop:artifact -- "${args[@]}" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: desktop-${{ matrix.platform }}-${{ matrix.arch }} + path: release/* + if-no-files-found: error + + release: + name: Publish GitHub Release + needs: [preflight, build] + runs-on: ubuntu-24.04 + steps: + - name: Download all desktop artifacts + uses: actions/download-artifact@v4 + with: + pattern: desktop-* + merge-multiple: true + path: release-assets + + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: T3 Code v${{ needs.preflight.outputs.version }} + generate_release_notes: true + files: release-assets/* + fail_on_unmatched_files: true diff --git a/README.md b/README.md index 9c231a3bf9d15dcf4c4b2add9582a97774b2f6c7..d2d87f8f25f399eba9e1c472a08af57a8ff4f7aa 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,11 @@ npx t3 - `bun run build` — Builds contracts, web app, and server through Turbo. - `bun run typecheck` — Strict TypeScript checks for all packages. - `bun run test` — Runs workspace tests. +- `bun run dist:desktop:artifact -- --platform --target --arch ` — Builds a desktop artifact for a specific platform/target/arch. - `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. - `bun run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. +- `bun run dist:desktop:linux` — Builds a Linux AppImage into `./release`. +- `bun run dist:desktop:win` — Builds a Windows NSIS installer into `./release`. ### Desktop `.dmg` packaging notes @@ -86,6 +89,12 @@ npx t3 - Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic. - Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. - To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage` +- To allow code-signing/notarization when configured in CI/secrets, add: `--signed`. +- Windows `--signed` uses Azure Trusted Signing and expects: + `AZURE_TRUSTED_SIGNING_ENDPOINT`, `AZURE_TRUSTED_SIGNING_ACCOUNT_NAME`, + `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME`, and `AZURE_TRUSTED_SIGNING_PUBLISHER_NAME`. +- Azure authentication env vars are also required (for example service principal with secret): + `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`. ### Running multiple dev instances @@ -122,3 +131,6 @@ Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI ## CI quality gates - `.github/workflows/ci.yml` runs `bun run lint`, `bun run typecheck`, and `bun run test` on pull requests and pushes to `main`. +- `.github/workflows/release.yml` builds macOS (`arm64` and `x64`), Linux (`x64`), and Windows (`x64`) desktop artifacts from a single `v*.*.*` tag and publishes one GitHub release. +- The release workflow auto-enables signing only when secrets are present: Apple credentials for macOS and Azure Trusted Signing credentials for Windows. Without secrets, it still releases unsigned artifacts. +- See `docs/release.md` for full release/signing setup checklist. diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000000000000000000000000000000000000..d99bf8fc236b9c9b9328df2410d4f2b9cf259a10 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,105 @@ +# Release Checklist + +This document covers how to run desktop releases from one tag, first without signing, then with signing. + +## What the workflow does + +- Trigger: push tag matching `v*.*.*`. +- Runs quality gates first: lint, typecheck, test. +- Builds four artifacts in parallel: + - macOS `arm64` DMG + - macOS `x64` DMG + - Linux `x64` AppImage + - Windows `x64` NSIS installer +- Publishes one GitHub Release with all produced files. +- Signing is optional and auto-detected per platform from secrets. + +## 1) Dry-run release without signing + +Use this first to validate the release pipeline. + +1. Confirm no signing secrets are required for this test. +2. Create a test tag: + - `git tag v0.0.0-test.1` + - `git push origin v0.0.0-test.1` +3. Wait for `.github/workflows/release.yml` to finish. +4. Verify the GitHub Release contains all platform artifacts. +5. Download each artifact and sanity-check installation on each OS. + +## 2) Apple signing + notarization setup (macOS) + +Required secrets used by the workflow: + +- `CSC_LINK` +- `CSC_KEY_PASSWORD` +- `APPLE_API_KEY` +- `APPLE_API_KEY_ID` +- `APPLE_API_ISSUER` + +Checklist: + +1. Apple Developer account access: + - Team has rights to create Developer ID certificates. +2. Create `Developer ID Application` certificate. +3. Export certificate + private key as `.p12` from Keychain. +4. Base64-encode the `.p12` and store as `CSC_LINK`. +5. Store the `.p12` export password as `CSC_KEY_PASSWORD`. +6. In App Store Connect, create an API key (Team key). +7. Add API key values: + - `APPLE_API_KEY`: contents of the downloaded `.p8` + - `APPLE_API_KEY_ID`: Key ID + - `APPLE_API_ISSUER`: Issuer ID +8. Re-run a tag release and confirm macOS artifacts are signed/notarized. + +Notes: + +- `APPLE_API_KEY` is stored as raw key text in secrets. +- The workflow writes it to a temporary `AuthKey_.p8` file at runtime. + +## 3) Azure Trusted Signing setup (Windows) + +Required secrets used by the workflow: + +- `AZURE_TENANT_ID` +- `AZURE_CLIENT_ID` +- `AZURE_CLIENT_SECRET` +- `AZURE_TRUSTED_SIGNING_ENDPOINT` +- `AZURE_TRUSTED_SIGNING_ACCOUNT_NAME` +- `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME` +- `AZURE_TRUSTED_SIGNING_PUBLISHER_NAME` + +Checklist: + +1. Create Azure Trusted Signing account and certificate profile. +2. Record ATS values: + - Endpoint + - Account name + - Certificate profile name + - Publisher name +3. Create/choose an Entra app registration (service principal). +4. Grant service principal permissions required by Trusted Signing. +5. Create a client secret for the service principal. +6. Add Azure secrets listed above in GitHub Actions secrets. +7. Re-run a tag release and confirm Windows installer is signed. + +## 4) Ongoing release checklist + +1. Ensure `main` is green in CI. +2. Bump app version as needed. +3. Create release tag: `vX.Y.Z`. +4. Push tag. +5. Verify workflow steps: + - preflight passes + - all matrix builds pass + - release job uploads expected files +6. Smoke test downloaded artifacts. + +## 5) Troubleshooting + +- macOS build unsigned when expected signed: + - Check all Apple secrets are populated and non-empty. +- Windows build unsigned when expected signed: + - Check all Azure ATS and auth secrets are populated and non-empty. +- Build fails with signing error: + - Retry with secrets removed to confirm unsigned path still works. + - Re-check certificate/profile names and tenant/client credentials. diff --git a/package.json b/package.json index 94b1db738c91119bfe6cf0077097fc281521ecfe..1a32dee5e30107bc5e37f70e5781ddb3e2712cae 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,12 @@ "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", "fmt": "oxfmt", "build:contracts": "turbo run build --filter=@t3tools/contracts", - "dist:desktop:dmg": "node scripts/build-desktop-dmg.mjs", - "dist:desktop:dmg:arm64": "node scripts/build-desktop-dmg.mjs --arch arm64", - "dist:desktop:dmg:x64": "node scripts/build-desktop-dmg.mjs --arch x64", + "dist:desktop:artifact": "node scripts/build-desktop-artifact.mjs", + "dist:desktop:dmg": "node scripts/build-desktop-artifact.mjs --platform mac --target dmg", + "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.mjs --platform mac --target dmg --arch arm64", + "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.mjs --platform mac --target dmg --arch x64", + "dist:desktop:linux": "node scripts/build-desktop-artifact.mjs --platform linux --target AppImage --arch x64", + "dist:desktop:win": "node scripts/build-desktop-artifact.mjs --platform win --target nsis --arch x64", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" }, diff --git a/scripts/build-desktop-artifact.mjs b/scripts/build-desktop-artifact.mjs new file mode 100644 index 0000000000000000000000000000000000000000..491dd01c30e528d971b498edcded5c05bde1ed07 --- /dev/null +++ b/scripts/build-desktop-artifact.mjs @@ -0,0 +1,672 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, ".."); +const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png"); +const DIRECT_EXECUTION_PATH = process.argv[1] ? path.resolve(process.argv[1]) : null; + +const PLATFORM_CONFIG = { + mac: { + hostPlatform: "darwin", + description: "macOS", + cliFlag: "--mac", + defaultTarget: "dmg", + targetChoices: ["dmg"], + archChoices: ["arm64", "x64", "universal"], + }, + linux: { + hostPlatform: "linux", + description: "Linux", + cliFlag: "--linux", + defaultTarget: "AppImage", + targetChoices: ["AppImage"], + archChoices: ["x64", "arm64"], + }, + win: { + hostPlatform: "win32", + description: "Windows", + cliFlag: "--win", + defaultTarget: "nsis", + targetChoices: ["nsis"], + archChoices: ["x64", "arm64"], + }, +}; + +function printUsage() { + console.log(`Build a desktop artifact for T3 Code. + +Usage: + node scripts/build-desktop-artifact.mjs [options] + +Options: + --platform Target platform (default: current host platform) + --target Target format (mac: dmg, linux: AppImage, win: nsis) + --arch Target arch (platform-dependent default) + --version App version in artifact metadata (default: apps/server version) + --output-dir Output folder for final artifacts (default: release) + --skip-build Skip 'bun run build:desktop' (requires existing dist artifacts) + --keep-stage Keep temporary staging directory + --signed Enable signing/notarization auto-discovery in electron-builder + (Windows uses Azure Trusted Signing) + --help Show this message +`); +} + +function fail(message) { + console.error(`[desktop-artifact] ${message}`); + process.exit(1); +} + +function detectHostBuildPlatform(hostPlatform) { + if (hostPlatform === "darwin") return "mac"; + if (hostPlatform === "linux") return "linux"; + if (hostPlatform === "win32") return "win"; + return null; +} + +function normalizePlatform(value) { + const normalized = value.toLowerCase(); + if (normalized === "mac" || normalized === "macos" || normalized === "darwin") return "mac"; + if (normalized === "linux") return "linux"; + if (normalized === "win" || normalized === "windows" || normalized === "win32") return "win"; + return null; +} + +function normalizeChoice(inputValue, choices) { + const normalized = inputValue.toLowerCase(); + for (const choice of choices) { + if (choice.toLowerCase() === normalized) { + return choice; + } + } + return null; +} + +function formatChoices(values) { + return values.join(", "); +} + +function readRequiredEnvironmentVariable(name, contextMessage) { + const value = process.env[name]; + if (typeof value !== "string" || value.trim().length === 0) { + fail(`Missing ${name}${contextMessage ? ` (${contextMessage})` : ""}.`); + } + return value.trim(); +} + +function readOptionalEnvironmentVariable(name) { + const value = process.env[name]; + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function getAzureTrustedSigningOptions() { + return { + publisherName: readRequiredEnvironmentVariable( + "AZURE_TRUSTED_SIGNING_PUBLISHER_NAME", + "required for Windows --signed", + ), + endpoint: readRequiredEnvironmentVariable( + "AZURE_TRUSTED_SIGNING_ENDPOINT", + "required for Windows --signed", + ), + certificateProfileName: readRequiredEnvironmentVariable( + "AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME", + "required for Windows --signed", + ), + codeSigningAccountName: readRequiredEnvironmentVariable( + "AZURE_TRUSTED_SIGNING_ACCOUNT_NAME", + "required for Windows --signed", + ), + fileDigest: readOptionalEnvironmentVariable("AZURE_TRUSTED_SIGNING_FILE_DIGEST") ?? "SHA256", + timestampDigest: + readOptionalEnvironmentVariable("AZURE_TRUSTED_SIGNING_TIMESTAMP_DIGEST") ?? "SHA256", + timestampRfc3161: + readOptionalEnvironmentVariable("AZURE_TRUSTED_SIGNING_TIMESTAMP_RFC3161") ?? + "http://timestamp.acs.microsoft.com", + }; +} + +function getDefaultArch(platform) { + const config = PLATFORM_CONFIG[platform]; + if (!config) { + return "x64"; + } + + if (process.arch === "arm64" && config.archChoices.includes("arm64")) { + return "arm64"; + } + if (process.arch === "x64" && config.archChoices.includes("x64")) { + return "x64"; + } + + return config.archChoices[0] ?? "x64"; +} + +function parseArgs(argv) { + const hostPlatform = detectHostBuildPlatform(process.platform); + const parsed = { + platform: hostPlatform, + target: null, + arch: null, + version: null, + skipBuild: false, + keepStage: false, + signed: false, + outputDir: path.join(repoRoot, "release"), + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--help") { + parsed.help = true; + continue; + } + if (arg === "--skip-build") { + parsed.skipBuild = true; + continue; + } + if (arg === "--keep-stage") { + parsed.keepStage = true; + continue; + } + if (arg === "--signed") { + parsed.signed = true; + continue; + } + + if ( + arg === "--platform" || + arg === "--target" || + arg === "--arch" || + arg === "--version" || + arg === "--output-dir" + ) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + fail(`Missing value for ${arg}`); + } + index += 1; + + if (arg === "--platform") { + const platform = normalizePlatform(value); + if (!platform) { + fail(`Invalid platform '${value}'. Expected one of: mac, linux, win`); + } + parsed.platform = platform; + continue; + } + + if (arg === "--target") { + parsed.target = value; + continue; + } + + if (arg === "--arch") { + parsed.arch = value; + continue; + } + + if (arg === "--version") { + parsed.version = value; + continue; + } + + if (arg === "--output-dir") { + parsed.outputDir = path.resolve(repoRoot, value); + continue; + } + } + + fail(`Unknown argument: ${arg}`); + } + + if (!parsed.platform) { + const hosts = ["darwin", "linux", "win32"].join(", "); + fail(`Unsupported host platform '${process.platform}'. Supported hosts: ${hosts}`); + } + + const config = PLATFORM_CONFIG[parsed.platform]; + if (!config) { + fail(`Unsupported platform '${parsed.platform}'`); + } + + const target = + parsed.target === null + ? config.defaultTarget + : normalizeChoice(parsed.target, config.targetChoices); + if (!target) { + fail( + `Invalid target '${parsed.target}'. Supported targets for ${parsed.platform}: ${formatChoices( + config.targetChoices, + )}`, + ); + } + + const arch = parsed.arch === null ? getDefaultArch(parsed.platform) : normalizeChoice(parsed.arch, config.archChoices); + if (!arch) { + fail( + `Invalid arch '${parsed.arch}'. Supported arches for ${parsed.platform}: ${formatChoices( + config.archChoices, + )}`, + ); + } + + return { + ...parsed, + target, + arch, + }; +} + +async function readJson(filePath) { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw); +} + +async function exists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function run(command, args, { cwd, env = process.env, stdio = "inherit" } = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env, + stdio, + }); + + child.on("error", reject); + child.on("exit", (code, signal) => { + if (signal) { + reject(new Error(`${command} exited via signal ${signal}`)); + return; + } + if (code !== 0) { + reject(new Error(`${command} exited with code ${code}`)); + return; + } + resolve(); + }); + }); +} + +async function generateMacIconSet(sourcePng, targetIcns, tmpRoot) { + const iconsetDir = path.join(tmpRoot, "icon.iconset"); + await fs.mkdir(iconsetDir, { recursive: true }); + + const iconSizes = [16, 32, 128, 256, 512]; + for (const size of iconSizes) { + await run( + "sips", + [ + "-z", + String(size), + String(size), + sourcePng, + "--out", + path.join(iconsetDir, `icon_${size}x${size}.png`), + ], + { stdio: "ignore" }, + ); + + const retinaSize = size * 2; + await run( + "sips", + [ + "-z", + String(retinaSize), + String(retinaSize), + sourcePng, + "--out", + path.join(iconsetDir, `icon_${size}x${size}@2x.png`), + ], + { stdio: "ignore" }, + ); + } + + await run("iconutil", ["-c", "icns", iconsetDir, "-o", targetIcns], { stdio: "ignore" }); +} + +async function stageMacIcons(stageResourcesDir) { + if (!(await exists(PRODUCTION_ICON_SOURCE))) { + fail(`Production icon source is missing at ${PRODUCTION_ICON_SOURCE}`); + } + + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3code-icon-build-")); + + try { + const iconPngPath = path.join(stageResourcesDir, "icon.png"); + const iconIcnsPath = path.join(stageResourcesDir, "icon.icns"); + + await run("sips", ["-z", "512", "512", PRODUCTION_ICON_SOURCE, "--out", iconPngPath], { + stdio: "ignore", + }); + + await generateMacIconSet(PRODUCTION_ICON_SOURCE, iconIcnsPath, tmpRoot); + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }); + } +} + +async function validateBundledClientAssets(clientDir) { + const indexPath = path.join(clientDir, "index.html"); + const indexHtml = await fs.readFile(indexPath, "utf8"); + const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1]); + const missing = []; + + for (const ref of refs) { + const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? ""; + if (!normalizedRef) continue; + if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue; + if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue; + + const ext = path.extname(normalizedRef); + if (!ext) continue; + + const relativePath = normalizedRef.replace(/^\/+/, ""); + const assetPath = path.join(clientDir, relativePath); + if (!(await exists(assetPath))) { + missing.push(normalizedRef); + } + } + + if (missing.length > 0) { + const preview = missing.slice(0, 6).join(", "); + const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; + fail( + `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, + ); + } +} + +function readWorkspaceCatalog(rootPackageJson) { + const workspaces = rootPackageJson?.workspaces; + if (!workspaces || typeof workspaces !== "object" || Array.isArray(workspaces)) { + return {}; + } + + const catalog = workspaces.catalog; + if (!catalog || typeof catalog !== "object" || Array.isArray(catalog)) { + return {}; + } + + return catalog; +} + +function resolveCatalogDependencies(dependencies, catalog, dependencySourceLabel) { + return Object.fromEntries( + Object.entries(dependencies).map(([dependencyName, spec]) => { + if (typeof spec !== "string" || !spec.startsWith("catalog:")) { + return [dependencyName, spec]; + } + + const catalogKey = spec.slice("catalog:".length).trim(); + const lookupKey = catalogKey.length > 0 ? catalogKey : dependencyName; + const resolvedSpec = catalog[lookupKey]; + if (typeof resolvedSpec !== "string" || resolvedSpec.length === 0) { + fail( + `Unable to resolve '${spec}' for ${dependencySourceLabel} dependency '${dependencyName}'. ` + + `Expected key '${lookupKey}' in root workspace catalog.`, + ); + } + + return [dependencyName, resolvedSpec]; + }), + ); +} + +function createBuildConfig(platform, target, productName, signed) { + const buildConfig = { + appId: "com.t3tools.t3code", + productName, + artifactName: "T3-Code-${version}-${arch}.${ext}", + directories: { + buildResources: "apps/desktop/resources", + }, + }; + + if (platform === "mac") { + buildConfig.mac = { + target: [target], + icon: "icon.icns", + category: "public.app-category.developer-tools", + }; + } + + if (platform === "linux") { + buildConfig.linux = { + target: [target], + icon: "icon.png", + category: "Development", + }; + } + + if (platform === "win") { + const winConfig = { + target: [target], + icon: "icon.ico", + }; + if (signed) { + winConfig.azureSignOptions = getAzureTrustedSigningOptions(); + } + buildConfig.win = winConfig; + } + + return buildConfig; +} + +async function assertPlatformBuildResources(platform, stageResourcesDir) { + if (platform === "mac") { + await stageMacIcons(stageResourcesDir); + return; + } + + if (platform === "linux") { + const iconPath = path.join(stageResourcesDir, "icon.png"); + if (!(await exists(iconPath))) { + fail(`Missing Linux icon at ${iconPath}`); + } + return; + } + + if (platform === "win") { + const iconPath = path.join(stageResourcesDir, "icon.ico"); + if (!(await exists(iconPath))) { + fail(`Missing Windows icon at ${iconPath}`); + } + } +} + +export async function main(argv = process.argv.slice(2)) { + const options = parseArgs(argv); + if (options.help) { + printUsage(); + return; + } + + const hostBuildPlatform = detectHostBuildPlatform(process.platform); + if (!hostBuildPlatform) { + fail(`Unsupported host platform '${process.platform}'.`); + } + + const platformConfig = PLATFORM_CONFIG[options.platform]; + if (!platformConfig) { + fail(`Unsupported platform '${options.platform}'.`); + } + + if (hostBuildPlatform !== options.platform) { + fail( + `Platform '${options.platform}' must run on ${platformConfig.description} (${platformConfig.hostPlatform}). ` + + `Current host is ${process.platform}.`, + ); + } + + const rootPkg = await readJson(path.join(repoRoot, "package.json")); + const desktopPkg = await readJson(path.join(repoRoot, "apps/desktop/package.json")); + const serverPkg = await readJson(path.join(repoRoot, "apps/server/package.json")); + + const electronVersion = desktopPkg.dependencies?.electron; + if (typeof electronVersion !== "string" || electronVersion.length === 0) { + fail("Could not resolve apps/desktop electron dependency version."); + } + + const dependencies = serverPkg.dependencies; + if (!dependencies || Object.keys(dependencies).length === 0) { + fail("Could not resolve production dependencies from apps/server/package.json."); + } + + const resolvedDependencies = resolveCatalogDependencies( + dependencies, + readWorkspaceCatalog(rootPkg), + "apps/server", + ); + + const appVersion = options.version ?? serverPkg.version ?? "0.1.0"; + const stageRoot = path.join(os.tmpdir(), `t3code-desktop-${options.platform}-stage`); + const stageAppDir = path.join(stageRoot, "app"); + const stageResourcesDir = path.join(stageAppDir, "apps/desktop/resources"); + const distDirs = { + desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"), + desktopResources: path.join(repoRoot, "apps/desktop/resources"), + serverDist: path.join(repoRoot, "apps/server/dist"), + }; + const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html"); + + if (!options.skipBuild) { + console.log("[desktop-artifact] Building desktop/server/web artifacts..."); + await run("bun", ["run", "build:desktop"], { cwd: repoRoot }); + } + + for (const [label, dir] of Object.entries(distDirs)) { + if (!(await exists(dir))) { + fail(`Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`); + } + } + + if (!(await exists(bundledClientEntry))) { + fail(`Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`); + } + + await validateBundledClientAssets(path.dirname(bundledClientEntry)); + + await fs.rm(stageRoot, { recursive: true, force: true }); + await fs.mkdir(path.join(stageAppDir, "apps/desktop"), { recursive: true }); + await fs.mkdir(path.join(stageAppDir, "apps/server"), { recursive: true }); + + console.log("[desktop-artifact] Staging release app..."); + await fs.cp(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"), { + recursive: true, + }); + await fs.cp(distDirs.desktopResources, stageResourcesDir, { + recursive: true, + }); + await fs.cp(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"), { recursive: true }); + + await assertPlatformBuildResources(options.platform, stageResourcesDir); + + const stagePackageJson = { + name: "t3-code-desktop", + version: appVersion, + private: true, + description: "T3 Code desktop build", + author: "T3 Tools", + main: "apps/desktop/dist-electron/main.js", + build: createBuildConfig( + options.platform, + options.target, + desktopPkg.productName ?? "T3 Code", + options.signed, + ), + dependencies: resolvedDependencies, + devDependencies: { + electron: electronVersion, + }, + }; + + await fs.writeFile( + path.join(stageAppDir, "package.json"), + `${JSON.stringify(stagePackageJson, null, 2)}\n`, + "utf8", + ); + + console.log("[desktop-artifact] Installing staged production dependencies..."); + await run("bun", ["install", "--production"], { cwd: stageAppDir }); + + const buildEnv = { + ...process.env, + }; + if (!options.signed) { + buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; + } + + const buildArgs = [ + "--bun", + "electron-builder", + platformConfig.cliFlag, + options.target, + `--${options.arch}`, + "--publish", + "never", + ]; + + console.log( + `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion})...`, + ); + await run("bunx", buildArgs, { cwd: stageAppDir, env: buildEnv }); + + const stageDistDir = path.join(stageAppDir, "dist"); + if (!(await exists(stageDistDir))) { + fail(`Build completed but dist directory was not found at ${stageDistDir}`); + } + + const stageEntries = await fs.readdir(stageDistDir, { withFileTypes: true }); + await fs.mkdir(options.outputDir, { recursive: true }); + + const copiedArtifacts = []; + for (const entry of stageEntries) { + if (!entry.isFile()) continue; + + const from = path.join(stageDistDir, entry.name); + const to = path.join(options.outputDir, entry.name); + await fs.copyFile(from, to); + copiedArtifacts.push(to); + } + + if (copiedArtifacts.length === 0) { + fail(`Build completed but no files were produced in ${stageDistDir}`); + } + + if (!options.keepStage) { + await fs.rm(stageRoot, { recursive: true, force: true }); + } + + console.log("[desktop-artifact] Done. Artifacts:"); + for (const artifact of copiedArtifacts) { + console.log(` - ${artifact}`); + } +} + +const scriptPath = fileURLToPath(import.meta.url); +const isDirectExecution = DIRECT_EXECUTION_PATH === scriptPath; + +if (isDirectExecution) { + main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + fail(message); + }); +} diff --git a/scripts/build-desktop-dmg.mjs b/scripts/build-desktop-dmg.mjs index 1552dc2d2162ec21c7896fadd6d27c3acc07eedd..24c61f7734cd8c079276496b2c492698b39e0a4d 100644 --- a/scripts/build-desktop-dmg.mjs +++ b/scripts/build-desktop-dmg.mjs @@ -1,392 +1,9 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { main } from "./build-desktop-artifact.mjs"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(__dirname, ".."); -const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png"); - -const ARCH_CHOICES = new Set(["arm64", "x64", "universal"]); - -function printUsage() { - console.log(`Build a shareable macOS .dmg for T3 Code desktop. - -Usage: - node scripts/build-desktop-dmg.mjs [options] - -Options: - --arch Target arch (default: host arch if supported) - --version App version in DMG metadata (default: apps/server version) - --output-dir Output folder for final artifacts (default: release) - --skip-build Skip 'bun run build:desktop' (requires existing dist artifacts) - --keep-stage Keep temporary staging directory - --signed Allow automatic code signing discovery - --help Show this message -`); -} - -function fail(message) { +main(["--platform", "mac", "--target", "dmg", ...process.argv.slice(2)]).catch((error) => { + const message = error instanceof Error ? error.message : String(error); console.error(`[desktop-dmg] ${message}`); process.exit(1); -} - -function parseArgs(argv) { - const defaultArch = process.arch === "arm64" || process.arch === "x64" ? process.arch : "arm64"; - const options = { - arch: defaultArch, - version: null, - skipBuild: false, - keepStage: false, - signed: false, - outputDir: path.join(repoRoot, "release"), - }; - - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (arg === "--help") { - options.help = true; - continue; - } - if (arg === "--skip-build") { - options.skipBuild = true; - continue; - } - if (arg === "--keep-stage") { - options.keepStage = true; - continue; - } - if (arg === "--signed") { - options.signed = true; - continue; - } - if (arg === "--arch" || arg === "--version" || arg === "--output-dir") { - const value = argv[i + 1]; - if (!value || value.startsWith("--")) { - fail(`Missing value for ${arg}`); - } - i += 1; - if (arg === "--arch") options.arch = value; - if (arg === "--version") options.version = value; - if (arg === "--output-dir") options.outputDir = path.resolve(repoRoot, value); - continue; - } - fail(`Unknown argument: ${arg}`); - } - - if (!ARCH_CHOICES.has(options.arch)) { - fail(`Invalid arch '${options.arch}'. Expected one of: ${Array.from(ARCH_CHOICES).join(", ")}`); - } - - return options; -} - -async function readJson(filePath) { - const raw = await fs.readFile(filePath, "utf8"); - return JSON.parse(raw); -} - -async function exists(filePath) { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } -} - -function run(command, args, { cwd, env = process.env, stdio = "inherit" } = {}) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd, - env, - stdio, - }); - - child.on("error", reject); - child.on("exit", (code, signal) => { - if (signal) { - reject(new Error(`${command} exited via signal ${signal}`)); - return; - } - if (code !== 0) { - reject(new Error(`${command} exited with code ${code}`)); - return; - } - resolve(); - }); - }); -} - -async function generateMacIconSet(sourcePng, targetIcns, tmpRoot) { - const iconsetDir = path.join(tmpRoot, "icon.iconset"); - await fs.mkdir(iconsetDir, { recursive: true }); - - const iconSizes = [16, 32, 128, 256, 512]; - for (const size of iconSizes) { - await run( - "sips", - [ - "-z", - String(size), - String(size), - sourcePng, - "--out", - path.join(iconsetDir, `icon_${size}x${size}.png`), - ], - { stdio: "ignore" }, - ); - const retinaSize = size * 2; - await run( - "sips", - [ - "-z", - String(retinaSize), - String(retinaSize), - sourcePng, - "--out", - path.join(iconsetDir, `icon_${size}x${size}@2x.png`), - ], - { stdio: "ignore" }, - ); - } - - await run("iconutil", ["-c", "icns", iconsetDir, "-o", targetIcns], { stdio: "ignore" }); -} - -async function stageProductionIcons(stageResourcesDir) { - if (!(await exists(PRODUCTION_ICON_SOURCE))) { - fail(`Production icon source is missing at ${PRODUCTION_ICON_SOURCE}`); - } - - const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3code-icon-build-")); - try { - const iconPngPath = path.join(stageResourcesDir, "icon.png"); - const iconIcnsPath = path.join(stageResourcesDir, "icon.icns"); - await run("sips", ["-z", "512", "512", PRODUCTION_ICON_SOURCE, "--out", iconPngPath], { - stdio: "ignore", - }); - await generateMacIconSet(PRODUCTION_ICON_SOURCE, iconIcnsPath, tmpRoot); - } finally { - await fs.rm(tmpRoot, { recursive: true, force: true }); - } -} - -async function validateBundledClientAssets(clientDir) { - const indexPath = path.join(clientDir, "index.html"); - const indexHtml = await fs.readFile(indexPath, "utf8"); - const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1]); - const missing = []; - - for (const ref of refs) { - const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? ""; - if (!normalizedRef) continue; - if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue; - if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue; - - const ext = path.extname(normalizedRef); - if (!ext) continue; - - const relativePath = normalizedRef.replace(/^\/+/, ""); - const assetPath = path.join(clientDir, relativePath); - if (!(await exists(assetPath))) { - missing.push(normalizedRef); - } - } - - if (missing.length > 0) { - const preview = missing.slice(0, 6).join(", "); - const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; - fail( - `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, - ); - } -} - -function readWorkspaceCatalog(rootPackageJson) { - const workspaces = rootPackageJson?.workspaces; - if (!workspaces || typeof workspaces !== "object" || Array.isArray(workspaces)) { - return {}; - } - - const catalog = workspaces.catalog; - if (!catalog || typeof catalog !== "object" || Array.isArray(catalog)) { - return {}; - } - - return catalog; -} - -function resolveCatalogDependencies(dependencies, catalog, dependencySourceLabel) { - return Object.fromEntries( - Object.entries(dependencies).map(([dependencyName, spec]) => { - if (typeof spec !== "string" || !spec.startsWith("catalog:")) { - return [dependencyName, spec]; - } - - const catalogKey = spec.slice("catalog:".length).trim(); - const lookupKey = catalogKey.length > 0 ? catalogKey : dependencyName; - const resolvedSpec = catalog[lookupKey]; - if (typeof resolvedSpec !== "string" || resolvedSpec.length === 0) { - fail( - `Unable to resolve '${spec}' for ${dependencySourceLabel} dependency '${dependencyName}'. ` + - `Expected key '${lookupKey}' in root workspace catalog.`, - ); - } - - return [dependencyName, resolvedSpec]; - }), - ); -} - -async function main() { - const options = parseArgs(process.argv.slice(2)); - if (options.help) { - printUsage(); - return; - } - - if (process.platform !== "darwin") { - fail("This script only builds macOS .dmg artifacts and must run on macOS."); - } - - const rootPkg = await readJson(path.join(repoRoot, "package.json")); - const desktopPkg = await readJson(path.join(repoRoot, "apps/desktop/package.json")); - const serverPkg = await readJson(path.join(repoRoot, "apps/server/package.json")); - - const electronVersion = desktopPkg.dependencies?.electron; - if (typeof electronVersion !== "string" || electronVersion.length === 0) { - fail("Could not resolve apps/desktop electron dependency version."); - } - - const dependencies = serverPkg.dependencies; - if (!dependencies || Object.keys(dependencies).length === 0) { - fail("Could not resolve production dependencies from apps/server/package.json."); - } - const resolvedDependencies = resolveCatalogDependencies( - dependencies, - readWorkspaceCatalog(rootPkg), - "apps/server", - ); - - const appVersion = options.version ?? serverPkg.version ?? "0.1.0"; - const stageRoot = path.join(os.tmpdir(), "t3code-desktop-dmg-stage"); - const stageAppDir = path.join(stageRoot, "app"); - const distDirs = { - desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"), - desktopResources: path.join(repoRoot, "apps/desktop/resources"), - serverDist: path.join(repoRoot, "apps/server/dist"), - }; - const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html"); - - if (!options.skipBuild) { - console.log("[desktop-dmg] Building desktop/server/web artifacts..."); - await run("bun", ["run", "build:desktop"], { cwd: repoRoot }); - } - - for (const [label, dir] of Object.entries(distDirs)) { - if (!(await exists(dir))) { - fail(`Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`); - } - } - if (!(await exists(bundledClientEntry))) { - fail( - `Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`, - ); - } - await validateBundledClientAssets(path.dirname(bundledClientEntry)); - - await fs.rm(stageRoot, { recursive: true, force: true }); - await fs.mkdir(path.join(stageAppDir, "apps/desktop"), { recursive: true }); - await fs.mkdir(path.join(stageAppDir, "apps/server"), { recursive: true }); - - console.log("[desktop-dmg] Staging release app..."); - await fs.cp(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"), { - recursive: true, - }); - await fs.cp(distDirs.desktopResources, path.join(stageAppDir, "apps/desktop/resources"), { - recursive: true, - }); - await fs.cp(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"), { recursive: true }); - await stageProductionIcons(path.join(stageAppDir, "apps/desktop/resources")); - - const stagePackageJson = { - name: "t3-code-desktop", - version: appVersion, - private: true, - description: "T3 Code desktop build", - author: "T3 Tools", - main: "apps/desktop/dist-electron/main.js", - build: { - appId: "com.t3tools.t3code", - productName: desktopPkg.productName ?? "T3 Code", - artifactName: "T3-Code-${version}-${arch}.${ext}", - directories: { - buildResources: "apps/desktop/resources", - }, - mac: { - target: ["dmg"], - icon: "icon.icns", - category: "public.app-category.developer-tools", - }, - }, - dependencies: resolvedDependencies, - devDependencies: { - electron: electronVersion, - }, - }; - - await fs.writeFile( - path.join(stageAppDir, "package.json"), - `${JSON.stringify(stagePackageJson, null, 2)}\n`, - "utf8", - ); - - console.log("[desktop-dmg] Installing staged production dependencies..."); - await run("bun", ["install", "--production"], { cwd: stageAppDir }); - - const buildEnv = { - ...process.env, - }; - if (!options.signed) { - buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; - } - - const buildArgs = ["--bun", "electron-builder", "--mac", "dmg", `--${options.arch}`]; - console.log(`[desktop-dmg] Building .dmg (arch=${options.arch}, version=${appVersion})...`); - await run("bunx", buildArgs, { cwd: stageAppDir, env: buildEnv }); - - const stageDistDir = path.join(stageAppDir, "dist"); - const stageFiles = await fs.readdir(stageDistDir); - const dmgFiles = stageFiles.filter((file) => file.endsWith(".dmg")); - if (dmgFiles.length === 0) { - fail(`Build completed but no .dmg found in ${stageDistDir}`); - } - - await fs.mkdir(options.outputDir, { recursive: true }); - const copiedArtifacts = []; - for (const file of stageFiles) { - if (!file.endsWith(".dmg") && !file.endsWith(".dmg.blockmap")) continue; - const from = path.join(stageDistDir, file); - const to = path.join(options.outputDir, file); - await fs.copyFile(from, to); - copiedArtifacts.push(to); - } - - if (!options.keepStage) { - await fs.rm(stageRoot, { recursive: true, force: true }); - } - - console.log("[desktop-dmg] Done. Artifacts:"); - for (const artifact of copiedArtifacts) { - console.log(` - ${artifact}`); - } -} - -main().catch((error) => { - const message = error instanceof Error ? error.message : String(error); - fail(message); }); -- 2.54.0