name: Release Desktop # Tag-driven release: push a tag like `v1.2.0` to build and publish the # desktop client for macOS (arm64 + x64) and Windows (x64). The tag is the # single source of truth for the version — it's written into package.json at # build time, so the maintainer never edits the version by hand. on: push: tags: - "v*" # Manual trigger for testing the full pipeline without cutting a real tag. 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-13 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 - 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 }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} run: | npm run build # Only export signing vars when provided. Empty strings are NOT the # same as unset to electron-builder: an empty CSC_LINK is treated as # a (broken) certificate path and aborts the build. Leaving them # unset makes electron-builder fall back to an unsigned build. if [ -n "$MAC_CSC_LINK" ]; then export CSC_LINK="$MAC_CSC_LINK" export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD" fi if [ -z "$WIN_CSC_LINK" ]; then unset WIN_CSC_LINK WIN_CSC_KEY_PASSWORD fi # Publish to the GitHub Release on tag pushes; otherwise build only. if [ "${{ github.event_name }}" = "push" ]; then PUBLISH=always else PUBLISH=never fi npx electron-builder ${{ matrix.eb_flags }} --publish "$PUBLISH" - name: Upload artifacts 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/*.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 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 from every per-platform artifact dir; only the # user-facing installers go to R2 (updater .yml/.blockmap stay on the # GitHub Release, which electron-updater reads directly). find artifacts -type f \( -name '*.dmg' -o -name '*.exe' \) -exec cp {} dist/ \; echo "Staged files:"; ls -la dist - name: Upload installers to R2 if: steps.guard.outputs.enabled == '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' env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} VER: ${{ steps.ver.outputs.version }} run: | # Map each installer filename to a platform id. dmg arch is in the # name (…-arm64.dmg / …-x64.dmg); .exe is the Windows installer. sql_file="$(mktemp)" # Clear the latest flag first; we re-set it for this version below. echo "UPDATE releases SET is_latest = 0;" >> "$sql_file" for f in dist/*; do base="$(basename "$f")" size="$(stat -c%s "$f")" case "$base" in *arm64.dmg) platform="mac-arm64" ;; *x64.dmg) platform="mac-x64" ;; *.exe) platform="win" ;; *) echo "Skipping unrecognized artifact: $base"; continue ;; esac key="v${VER}/${base}" echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, 1);" >> "$sql_file" done echo "==> D1 statements:"; cat "$sql_file" npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$sql_file"