name: Publish Desktop # STAGE 3 of the decoupled release pipeline: PROMOTE a built + notarized version # to "live". By this point: # - stage 1 (Release Desktop) built the installers, mirrored them to R2, and # registered them in D1 as unpublished (is_latest=0); # - stage 2 (local desktop/build/notarize-dmg.sh) notarized + stapled the mac # dmgs and re-uploaded the stapled bytes to R2. # # This workflow, triggered manually with the version to publish: # 1. pulls every artifact for that version back from R2, # 2. recomputes sha512 from the real (stapled) bytes and updates D1, # 3. flips is_latest=1 for that version (clearing the previous latest per # platform) UNLESS it's a pre-release, which is recorded but never latest, # 4. creates/updates the GitHub Release and attaches the installers. # # Run only after the mac dmgs for this version are notarized + re-uploaded. on: workflow_dispatch: inputs: version: description: "Version to publish (e.g. 1.2.0). Must already be built + notarized." type: string required: true make_latest: description: "Mark this version as latest on the site (uncheck for a dry re-hash only)." type: boolean default: true github_release: description: "Create/update the GitHub Release and attach installers." type: boolean default: true permissions: contents: write jobs: publish: name: Publish ${{ github.event.inputs.version }} runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Guard on Cloudflare secrets id: guard env: CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} run: | if [ -z "$CF_TOKEN" ]; then echo "::error::CLOUDFLARE_API_TOKEN not set — cannot publish." exit 1 fi - name: Resolve version artifacts from D1 id: rows env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} VER: ${{ github.event.inputs.version }} run: | # The build stage inserted one row per artifact with filename = v/. # Read them back so we know exactly which objects to pull from R2. out="$(npx --yes wrangler@latest d1 execute cow-desktop --remote --json \ --command "SELECT platform, filename FROM releases WHERE version = '${VER}';")" echo "$out" echo "$out" | node -e ' const fs = require("fs"); const data = JSON.parse(fs.readFileSync(0, "utf8")); const rows = (Array.isArray(data) ? data : [data]) .flatMap(r => (r.results || [])); if (!rows.length) { console.error("No D1 rows for this version — did stage 1 (build) run?"); process.exit(1); } fs.writeFileSync(process.env.GITHUB_OUTPUT, "count=" + rows.length + "\n", { flag: "a" }); fs.writeFileSync("rows.json", JSON.stringify(rows)); ' - name: Download version artifacts from R2 env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }} run: | mkdir -p dist # filename is "v/"; the R2 key is "desktop/". for filename in $(node -e 'JSON.parse(require("fs").readFileSync("rows.json")).forEach(r => console.log(r.filename))'); do base="$(basename "$filename")" key="desktop/${filename}" echo "==> Downloading r2://${R2_BUCKET}/${key} -> dist/${base}" npx --yes wrangler@latest r2 object get "${R2_BUCKET}/${key}" \ --file "dist/${base}" --remote done echo "Downloaded:"; ls -la dist - name: Reminder — mac dmgs must be notarized before publishing run: | # Stapling can only be validated on macOS (xcrun stapler validate), # which this Linux runner doesn't have. The authoritative check runs in # stage 2 (desktop/build/notarize-dmg.sh) before re-uploading to R2. # This step is just a loud reminder in the log. echo "::notice::Publishing assumes the mac dmgs pulled from R2 are already notarized + stapled (stage 2). If you skipped stage 2, users will hit Gatekeeper warnings." ls -la dist/*.dmg 2>/dev/null || echo "(no dmg in this version — win-only publish)" - name: Update D1 (recompute sha512 + set latest) env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} VER: ${{ github.event.inputs.version }} MAKE_LATEST: ${{ github.event.inputs.make_latest }} run: | sql_file="$(mktemp)" sha_for_file() { openssl dgst -sha512 -binary "$1" | openssl base64 -A; } # Pre-releases (e.g. 1.2.0-beta / -rc.1 / -test) are recorded but never # become latest, so the site keeps serving the last stable build. case "$VER" in *-*) is_pre=1 ;; *) is_pre=0 ;; esac if [ "$MAKE_LATEST" = "true" ] && [ "$is_pre" = "0" ]; then do_latest=1; echo "==> Publishing $VER as latest." else do_latest=0; echo "==> Publishing $VER without latest flag (pre-release or dry re-hash)." fi 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}" sha="$(sha_for_file "$f")" sha="${sha//\'/\'\'}" if [ "$do_latest" = "1" ]; then echo "UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';" >> "$sql_file" fi # Re-store the row with the authoritative (post-staple) sha and the # resolved latest flag. echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, sha512, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, '${sha}', ${do_latest});" >> "$sql_file" done echo "==> D1 statements:"; cat "$sql_file" npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$sql_file" - name: Create/update GitHub Release and attach installers if: github.event.inputs.github_release == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VER: ${{ github.event.inputs.version }} run: | tag="v${VER}" case "$VER" in *-*) prerelease="--prerelease" ;; *) prerelease="" ;; esac if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then gh release create "$tag" --repo "$GITHUB_REPOSITORY" \ --title "$tag" --generate-notes $prerelease fi # --clobber so re-runs overwrite the stapled/updated assets. gh release upload "$tag" dist/*.dmg dist/*.exe \ --repo "$GITHUB_REPOSITORY" --clobber