diff --git a/.github/workflows/desktop-macos.yml b/.github/workflows/desktop-macos.yml new file mode 100644 index 0000000000..96d99b6cff --- /dev/null +++ b/.github/workflows/desktop-macos.yml @@ -0,0 +1,91 @@ +name: Desktop (macOS) + +# Builds the Agent Canvas macOS DMG (Apple Silicon). +# pull_request: paths-filtered smoke build — the uploaded artifact is +# what a tester downloads to verify a PR on macOS. +# release published: rebuilds from the release tag and attaches the .dmg +# to the GitHub Release created by release-please. +# workflow_dispatch: manual escape hatch for any ref. +# The app is only ad-hoc signed (no signing certs exist for any platform); +# a downloaded DMG is quarantined by Gatekeeper, which reports the app as +# "damaged" until the attribute is cleared: +# xattr -d com.apple.quarantine "/Applications/Agent Canvas.app" +# Intel DMGs are not produced — the bundled uv/node runtimes are host-arch +# only, so Intel users build from source. +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/desktop-macos.yml + - electron/** + - electron-builder.config.mjs + - scripts/download-uv.mjs + - scripts/download-node.mjs + release: + types: [published] + +concurrency: + group: desktop-macos-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# `gh release upload` needs contents: write on release events. Fork PR runs +# are downgraded to a read-only token by GitHub automatically. +permissions: + contents: write + +jobs: + build-dmg: + name: Build macOS DMG + runs-on: macos-latest + timeout-minutes: 30 + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Node.js with npm cache + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build macOS DMG + env: + # Production analytics key only for release builds (same split as + # docker.yml); PR/manual runs get the staging key. Both are public + # client-side keys stored as repo vars — empty on fork PRs, which + # simply disables analytics in the built app. + VITE_POSTHOG_API_KEY: ${{ github.event_name == 'release' && vars.POSTHOG_PROD_KEY || vars.POSTHOG_STAGING_KEY }} + # Authenticates download-uv.mjs's GitHub API version lookup so it + # doesn't hit the unauthenticated per-IP rate limit on shared runners. + GITHUB_TOKEN: ${{ github.token }} + run: npm run build:desktop + + - name: Verify DMG output + run: | + ls -la dist-electron + dmg_count=$(find dist-electron -maxdepth 1 -name '*.dmg' | wc -l | tr -d ' ') + if [ "$dmg_count" -ne 1 ]; then + echo "::error::Expected exactly one DMG in dist-electron/, found $dmg_count" + exit 1 + fi + + - name: Upload DMG artifact + uses: actions/upload-artifact@v7 + with: + name: agent-canvas-macos-dmg + path: dist-electron/*.dmg + if-no-files-found: error + # The DMG is large (~175 MB); keep PR artifacts long enough for + # manual QA without hoarding storage. + retention-days: 14 + + - name: Attach DMG to GitHub release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$RELEASE_TAG" dist-electron/*.dmg --clobber diff --git a/electron-builder.config.mjs b/electron-builder.config.mjs index 5131e3ebd5..eb9c11a0e9 100644 --- a/electron-builder.config.mjs +++ b/electron-builder.config.mjs @@ -70,6 +70,15 @@ const RUNTIME_PACKAGES = ["sirv", "httpxy"]; const repoRoot = dirname(fileURLToPath(import.meta.url)); +// Root package.json is the single source of truth for the app version +// (release-please bumps it). electron/package.json is a minimal manifest +// stub pinned at 1.0.0 — `extraMetadata` below overrides its version at +// pack time so artifact names and app.getVersion() carry the released +// version instead. +const rootPackageJson = JSON.parse( + readFileSync(join(repoRoot, "package.json"), "utf8"), +); + /** * Strip the auto-bundled node_modules from the packaged app, then restore * the small runtime closure of RUNTIME_PACKAGES. @@ -203,6 +212,10 @@ const config = { productName: "Agent Canvas", copyright: "Copyright © 2025 All Hands AI", + // Stamp the packaged app with the released version (see rootPackageJson + // note above). + extraMetadata: { version: rootPackageJson.version }, + // Treat electron/ as the app root. electron/package.json provides the // Electron entry point without touching the npm-published root package.json. // `buildResources` points at electron/build-resources so electron-builder @@ -274,6 +287,12 @@ const config = { // Or use the dedicated script: // npm run build:desktop:universal // + // CAUTION: the bundled uv/node extraResources are downloaded for the + // BUILD HOST's architecture only (scripts/download-uv.mjs and + // download-node.mjs have no arch override), so a "universal" build still + // ships single-arch runtimes and breaks on the other architecture. Don't + // distribute universal DMGs until the download scripts support multi-arch. + // mac: { category: "public.app-category.developer-tools", target: [ @@ -296,6 +315,10 @@ const config = { { x: 410, y: 220, type: "link", path: "/Applications" }, ], window: { width: 540, height: 380 }, + // Default is "Agent Canvas--.dmg"; GitHub release assets + // mangle spaces, so keep the asset name literal (matches the nsis + // convention). ${version}/${arch}/${ext} are electron-builder macros. + artifactName: "Agent-Canvas-${version}-${arch}.${ext}", }, // ── Windows ──────────────────────────────────────────────────────────────── diff --git a/scripts/download-uv.mjs b/scripts/download-uv.mjs index 818c5dd8d2..48b4e114df 100644 --- a/scripts/download-uv.mjs +++ b/scripts/download-uv.mjs @@ -73,9 +73,15 @@ async function resolveVersion() { } console.log("[download-uv] Fetching latest uv version from GitHub API..."); + const headers = { "User-Agent": "agent-canvas-build" }; + // Unauthenticated api.github.com calls are rate-limited per IP (60/hour) — + // shared CI runner IPs exhaust that fast. CI passes GITHUB_TOKEN. + if (process.env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + } const data = await fetchJson( "https://api.github.com/repos/astral-sh/uv/releases/latest", - { "User-Agent": "agent-canvas-build" } + headers ); const version = data.tag_name?.replace(/^v/, ""); if (!version) throw new Error("Could not parse uv version from GitHub API");