Release Desktop #50
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release Desktop | |
| # STAGE 1 of the decoupled release pipeline: BUILD ONLY. | |
| # Builds the desktop client for macOS (arm64 + x64) and Windows (x64), mirrors | |
| # the installers to R2, and registers them in D1 as UNPUBLISHED (is_latest=0) | |
| # so the website keeps serving the previous release. It does NOT notarize | |
| # (Apple's notary service stalls this large bundle for hours) and does NOT | |
| # create a GitHub Release. | |
| # | |
| # Full flow: | |
| # 1. (this workflow) build + upload to R2 + D1 as unpublished. | |
| # 2. (local) download the mac dmgs, run desktop/build/notarize-dmg.sh to | |
| # notarize + staple + re-upload the stapled dmgs to R2. | |
| # 3. (Publish Desktop workflow) flip D1 is_latest=1 and attach GitHub | |
| # Release assets — makes the version live on the site. | |
| # | |
| # Manual only: run stage 1 via workflow_dispatch. Tag pushes do NOT trigger a | |
| # build, so cutting a release tag never rebuilds installers or overwrites R2. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: "Version to stamp (e.g. 1.0.0-test). Used for package.json and R2 path." | |
| type: string | |
| default: "0.0.0-dev" | |
| publish_r2: | |
| description: "Upload installers to R2 + register in D1 (needs Cloudflare secrets)" | |
| type: boolean | |
| default: false | |
| permissions: | |
| contents: write | |
| jobs: | |
| build: | |
| name: Build ${{ matrix.name }} | |
| runs-on: ${{ matrix.os }} | |
| strategy: | |
| # Don't cancel the other platforms if one fails — we want to see all | |
| # failures in a single run. | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - name: macOS arm64 | |
| os: macos-14 | |
| platform: mac | |
| arch: arm64 | |
| eb_flags: --mac --arm64 | |
| - name: macOS x64 | |
| os: macos-15-intel | |
| platform: mac | |
| arch: x64 | |
| eb_flags: --mac --x64 | |
| - name: Windows x64 | |
| os: windows-latest | |
| platform: win | |
| arch: x64 | |
| eb_flags: --win --x64 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Derive version | |
| # Tag push: strip the leading "v" from GITHUB_REF_NAME (e.g. v1.2.0). | |
| # Manual dispatch: use the provided version input. | |
| id: ver | |
| shell: bash | |
| run: | | |
| if [ "${{ github.event_name }}" = "push" ]; then | |
| ref="${GITHUB_REF_NAME:-}" | |
| echo "version=${ref#v}" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Set up Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: "20" | |
| - name: Build Python backend (PyInstaller) | |
| shell: bash | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -r desktop/build/requirements-desktop.txt | |
| pip install pyinstaller | |
| # Run from repo root so the spec's relative datas resolve correctly. | |
| pyinstaller desktop/build/cowagent-backend.spec \ | |
| --noconfirm \ | |
| --distpath desktop/build/dist \ | |
| --workpath desktop/build/build-work | |
| # PyInstaller follows whatever arch the host Python is, so a translated | |
| # toolchain would hand us a backend for the wrong arch. Shipping that in | |
| # an arm64 app puts the whole agent — and every browser it spawns — under | |
| # Rosetta, which is slow enough to look like a hang. Catch it here. | |
| - name: Verify backend architecture | |
| if: matrix.platform == 'mac' | |
| shell: bash | |
| run: | | |
| want=arm64 | |
| [ "${{ matrix.arch }}" = "arm64" ] || want=x86_64 | |
| got="$(lipo -archs desktop/build/dist/cowagent-backend/cowagent-backend)" | |
| echo "backend arch: $got (want $want)" | |
| if [ "$got" != "$want" ]; then | |
| echo "::error::backend is $got but this job targets $want" | |
| exit 1 | |
| fi | |
| - name: Install desktop deps | |
| working-directory: desktop | |
| run: npm ci | |
| # Bundle ripgrep on Windows so the grep tool has a fast backend out of the | |
| # box instead of falling back to PowerShell (slow, and prone to encoding / | |
| # path quirks). macOS is intentionally excluded: it always has a working | |
| # system `grep` that the tool uses correctly, so bundling rg there would | |
| # only add ~5MB for a marginal speed gain. Placed at resources/bin, wired | |
| # into PATH by python-manager. | |
| - name: Bundle ripgrep binary (Windows only) | |
| if: matrix.platform == 'win' | |
| shell: bash | |
| env: | |
| RG_VERSION: "15.2.0" | |
| run: | | |
| set -euo pipefail | |
| mkdir -p desktop/resources/bin | |
| url="https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/ripgrep-${RG_VERSION}-x86_64-pc-windows-msvc.zip" | |
| curl -fL "$url" -o rg.zip | |
| # -j flatten, -o overwrite; grab just the rg.exe from the archive. | |
| unzip -j -o rg.zip '*/rg.exe' -d desktop/resources/bin | |
| ls -l desktop/resources/bin | |
| - name: Write version into package.json | |
| working-directory: desktop | |
| shell: bash | |
| run: npm version "${{ steps.ver.outputs.version }}" --no-git-tag-version --allow-same-version | |
| # Compile renderer + main in its OWN step, alone, so the npm.cmd batch | |
| # wrapper (see the note on the build step below) can't take out anything | |
| # after it. | |
| - name: Compile (vite + tsc) | |
| working-directory: desktop | |
| shell: bash | |
| run: npm run build | |
| # Download the Windows signing CLI. The URL comes from a repo variable, so | |
| # nothing about the signing setup is hardcoded in a public workflow. Only | |
| # runs on the Windows leg and only when a URL is set; otherwise the build | |
| # stays unsigned. SIGNTOOL_PATH is exported for the next step's | |
| # electron-builder.win.js to invoke. | |
| - name: Download Windows signing CLI | |
| if: matrix.platform == 'win' && vars.SIGNTOOL_CLI_URL != '' | |
| shell: bash | |
| env: | |
| SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }} | |
| run: | | |
| mkdir -p "$RUNNER_TEMP/signtool" | |
| curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip" | |
| # Unzip and locate the signtool executable regardless of nesting. | |
| unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null | |
| exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)" | |
| if [ -z "$exe" ]; then | |
| echo "signtool.exe not found in downloaded archive" >&2 | |
| find "$RUNNER_TEMP/signtool" -type f >&2 | |
| exit 1 | |
| fi | |
| # Normalize to a Windows-style path for execFileSync in Node. | |
| echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV" | |
| echo "resolved signtool: $exe" | |
| - name: Build & publish (electron-builder) | |
| working-directory: desktop | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # Signing secrets are passed through as-is; we only export them to the | |
| # environment below when non-empty. An empty CSC_LINK would make | |
| # electron-builder try to load a bogus certificate and fail, so unset | |
| # is the correct state for unsigned builds. | |
| MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }} | |
| MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} | |
| # Windows code signing via the signing CLI. Credentials are | |
| # secrets; SIGNTOOL_PATH was exported by the download step above. | |
| # COW_SIGN_DRY_RUN (repo variable) lets us validate the whole pipeline | |
| # with a self-signed cert before buying a real one — no quota used. | |
| SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }} | |
| SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }} | |
| SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }} | |
| COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }} | |
| run: | | |
| # Pick the signing cert for THIS platform only. The mac and win secrets | |
| # are both present in the job env, but a mac cert must never leak into a | |
| # Windows build (electron-builder would try to load it and fail), and | |
| # vice versa. electron-builder reads a single CSC_LINK/CSC_KEY_PASSWORD | |
| # pair, so we set it per-platform. An empty CSC_LINK is treated by | |
| # electron-builder as a broken cert path, so we leave it entirely unset | |
| # for an unsigned build. | |
| # | |
| # NOTE: we only ever `export`, never `unset`, GitHub-injected env vars | |
| # (an `unset` can return non-zero and abort under errexit). | |
| # macOS keeps the classic CSC_LINK (.p12) flow. Windows no longer uses | |
| # a local .pfx (EV private keys can't be exported since 2023); it signs | |
| # via the CLI wired into electron-builder.win.js instead, using the | |
| # SIGNTOOL_* env already set above — nothing to export here. | |
| case "${{ matrix.platform }}" in | |
| mac) | |
| if [ -n "$MAC_CSC_LINK" ]; then | |
| export CSC_LINK="$MAC_CSC_LINK" | |
| export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD" | |
| fi | |
| ;; | |
| esac | |
| # Never let electron-builder publish: our publish target is a generic | |
| # (read-only) feed served from R2/D1, which it can't upload to. We mirror | |
| # installers to R2 and register them in D1 ourselves (publish-r2 job). | |
| # `--publish never` still emits the latest*.yml files. | |
| # | |
| # CONFIG PER PLATFORM: each platform loads its OWN dynamic config. | |
| # mac -> electron-builder.js (injects mac.binaries for signing) | |
| # win -> electron-builder.win.js (wires the sign hook; electron-builder | |
| # signs the app, backend and installer) | |
| # HISTORY: passing --config on Windows previously broke the build (no | |
| # installer, job still green). That happened because the MAC config | |
| # (electron-builder.js) was a no-op on Windows yet still disturbed the | |
| # run. The fix is a DEDICATED win config that correctly extends | |
| # config.win — not sharing the mac one. If a build ever runs WITHOUT | |
| # signing configured, electron-builder.win.js still returns the base | |
| # config unchanged (sign hook just skips), so the installer is still | |
| # produced. | |
| # | |
| # Invoke via `node <cli.js>` rather than `npx`: on Windows `npx` is | |
| # npx.cmd (a batch wrapper) and running it from this Git Bash step can | |
| # make bash return before the wrapped process finishes. node skips it. | |
| case "${{ matrix.platform }}" in | |
| mac) config_arg="--config electron-builder.js" ;; | |
| win) config_arg="--config electron-builder.win.js" ;; | |
| *) config_arg="" ;; | |
| esac | |
| node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never | |
| # Upload artifacts regardless of outcome, so a failed run still surfaces | |
| # the built installers (and, on success, the notarized+stapled dmg). | |
| - name: Upload artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| # One bundle per platform/arch so the publish job can collect them all. | |
| name: cowagent-${{ matrix.platform }}-${{ matrix.arch }} | |
| path: | | |
| desktop/release/*.dmg | |
| desktop/release/*.zip | |
| desktop/release/*.exe | |
| desktop/release/*.yml | |
| desktop/release/*.blockmap | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| # Mirror the release installers to R2 (CDN-backed) and register them in D1 so | |
| # cowagent.ai/download/{platform}/latest can resolve and count downloads. | |
| # Runs only on tag pushes, and is a no-op (skips) until the Cloudflare secrets | |
| # are configured, so it never blocks unsigned/dry builds. | |
| publish-r2: | |
| name: Publish to R2 + D1 | |
| # Require every platform in the build matrix to succeed before publishing, | |
| # so a release on R2/D1 is always complete (all installers present) rather | |
| # than partial. needs: build already gates on all matrix jobs succeeding. | |
| needs: build | |
| runs-on: ubuntu-latest | |
| # Run on a tag push, or on a manual dispatch when publish_r2 is checked. | |
| if: >- | |
| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || | |
| (github.event_name == 'workflow_dispatch' && github.event.inputs.publish_r2 == 'true') | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Guard on Cloudflare secrets | |
| id: guard | |
| env: | |
| CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| run: | | |
| if [ -n "$CF_TOKEN" ]; then | |
| echo "enabled=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "enabled=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping R2/D1 publish." | |
| fi | |
| - name: Derive version | |
| if: steps.guard.outputs.enabled == 'true' | |
| id: ver | |
| run: | | |
| if [ "${{ github.event_name }}" = "push" ]; then | |
| echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Download all build artifacts | |
| if: steps.guard.outputs.enabled == 'true' | |
| uses: actions/download-artifact@v4 | |
| with: | |
| path: artifacts | |
| - name: Stage installers | |
| if: steps.guard.outputs.enabled == 'true' | |
| id: stage | |
| run: | | |
| mkdir -p dist | |
| # Flatten installers + their .blockmap (used by electron-updater for | |
| # differential downloads) from every per-platform artifact dir. The | |
| # .yml feed is generated dynamically by the /update Function from D1, | |
| # so the yml files themselves don't need to go to R2. | |
| # .zip is the mac auto-update artifact (electron-updater's MacUpdater | |
| # can ONLY consume zip, not dmg — the dmg is for manual downloads). | |
| # Flattening hides collisions: two jobs emitting the same installer | |
| # name would silently overwrite each other and publish whichever | |
| # landed last. Refuse instead of guessing. | |
| dupes="$(find artifacts -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' \) \ | |
| -exec basename {} \; | sort | uniq -d)" | |
| if [ -n "$dupes" ]; then | |
| echo "::error::more than one build job produced: $dupes" | |
| exit 1 | |
| fi | |
| find artifacts -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.blockmap' \) -exec cp {} dist/ \; | |
| echo "Staged files:"; ls -la dist | |
| # When the whole matrix failed there's nothing to publish; flag it so | |
| # the R2/D1 steps skip instead of writing an empty/partial release. | |
| if [ -n "$(ls -A dist 2>/dev/null)" ]; then | |
| echo "has_files=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "has_files=false" >> "$GITHUB_OUTPUT" | |
| echo "::warning::No installers found in any artifact — skipping R2/D1 publish." | |
| fi | |
| - name: Upload installers to R2 | |
| if: steps.guard.outputs.enabled == 'true' && steps.stage.outputs.has_files == 'true' | |
| env: | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| VER: ${{ steps.ver.outputs.version }} | |
| run: | | |
| # Reuse the existing cow-skills bucket under a desktop/ prefix; this | |
| # is served by the cdn.cowagent.ai custom domain. | |
| for f in dist/*; do | |
| base="$(basename "$f")" | |
| key="desktop/v${VER}/${base}" | |
| echo "==> Uploading $base -> r2://cow-skills/$key" | |
| npx --yes wrangler@latest r2 object put "cow-skills/$key" \ | |
| --file "$f" --remote | |
| done | |
| - name: Register release rows in D1 | |
| if: steps.guard.outputs.enabled == 'true' && steps.stage.outputs.has_files == 'true' | |
| env: | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| VER: ${{ steps.ver.outputs.version }} | |
| run: | | |
| # This build job ALWAYS registers rows as unpublished (is_latest=0), so | |
| # /download/<p>/latest keeps serving the previous release and the new | |
| # version stays invisible on the site. macOS dmgs still need to be | |
| # notarized+stapled locally (build/notarize-dmg.sh) before they're | |
| # safe to ship. Promotion to latest happens later, only after | |
| # notarization, via the separate "Publish Desktop" workflow. | |
| echo "==> Registering $VER as unpublished (is_latest=0)." | |
| # Build one upsert per (version, platform) carrying both the dmg | |
| # (manual download) and the mac zip (auto-update) columns. See | |
| # .github/scripts/register-releases.mjs for the mapping. No --latest | |
| # here: rows stay unpublished until the publish workflow promotes them. | |
| node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql | |
| echo "==> D1 statements:"; cat d1.sql | |
| npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql |