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 - name: Install desktop deps working-directory: desktop run: npm ci - 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 ` 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). 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/

/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