build(desktop): decouple macOS notarization from CI into 3-stage release

This commit is contained in:
zhayujie
2026-07-06 19:24:55 +08:00
parent dd74d1dabe
commit cb31013584
7 changed files with 357 additions and 186 deletions

167
.github/workflows/publish-desktop.yml vendored Normal file
View File

@@ -0,0 +1,167 @@
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<VER>/<base>.
# 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<VER>/<base>"; the R2 key is "desktop/<filename>".
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

View File

@@ -1,9 +1,20 @@
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.
# 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.
#
# Push a tag like `v1.2.0` (or use workflow_dispatch) to run stage 1.
on:
push:
tags:
@@ -108,10 +119,6 @@ jobs:
# is the correct state for unsigned builds.
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
# electron-builder's built-in notarization reads these.
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: |
@@ -134,9 +141,12 @@ jobs:
# installers to R2 and register them in D1 ourselves (publish-r2 job).
# `--publish never` still emits the latest*.yml files.
# Use the dynamic config (electron-builder.js): it populates
# mac.binaries (backend Mach-O files to sign) and enables built-in
# notarization, which zips the .app and submits that (the path Apple
# actually accepts for this large bundle) then staples + packs the dmg.
# mac.binaries (backend Mach-O files to sign). Notarization is NOT done
# here — Apple's notary service keeps this large bundle "In Progress"
# for hours, so it's decoupled into a manual local step
# (desktop/build/notarize-dmg.sh) run after this build produces the
# signed dmg. The dmg is signed + hardened-runtime, so it only needs a
# ticket stapled later.
npx electron-builder ${{ matrix.eb_flags }} --config electron-builder.js --publish never
# Upload artifacts regardless of outcome, so a failed run still surfaces
@@ -250,21 +260,19 @@ jobs:
# name (…-arm64.dmg / …-x64.dmg); .exe is the Windows installer.
sql_file="$(mktemp)"
# Pre-releases (e.g. 1.0.0-test / -beta / -rc.1 / -alpha / -dev) are
# recorded but NEVER marked latest, so /download/<p>/latest keeps
# serving the last stable build. They also must not clear an existing
# stable's latest flag. Only a final version (no pre-release suffix)
# becomes the new latest and clears the previous one per platform.
case "$VER" in
*-*) is_latest=0; echo "==> $VER is a pre-release; not marking latest." ;;
*) is_latest=1; echo "==> $VER is a stable release; marking latest." ;;
esac
# 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)."
# Compute sha512 (base64, the format electron-updater validates) from
# the actual staged files rather than electron-builder's latest*.yml:
# stapling rewrites the dmg AFTER those yml files are generated, so
# the yml hashes are stale for mac. Hashing the real bytes keeps the
# /update feed (generated from D1) consistent with what R2 serves.
# the actual staged files rather than electron-builder's latest*.yml.
# NOTE: for macOS the dmg is re-hashed later by the publish workflow
# after stapling (stapling rewrites the dmg bytes), so the sha stored
# here is a placeholder for mac and authoritative only for win.
sha_for_file() { openssl dgst -sha512 -binary "$1" | openssl base64 -A; }
for f in dist/*; do
@@ -280,35 +288,9 @@ jobs:
sha="$(sha_for_file "$f")"
# SQL-escape single quotes defensively (base64 has none, but be safe).
sha="${sha//\'/\'\'}"
# Stable only: clear the previous latest for THIS platform first, so
# a partial backfill never wipes other platforms' latest flag.
if [ "$is_latest" = "1" ]; then
echo "UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';" >> "$sql_file"
fi
echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, sha512, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, '${sha}', ${is_latest});" >> "$sql_file"
# Never mark latest here — the row is unpublished until the separate
# publish workflow promotes it after notarization.
echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, sha512, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, '${sha}', 0);" >> "$sql_file"
done
echo "==> D1 statements:"; cat "$sql_file"
npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$sql_file"
# Attach installers to the GitHub Release for the tag. Only on tag
# pushes (manual dispatch has no tag to attach to). Pre-release suffix
# (e.g. 1.2.0-beta) marks the Release as a pre-release.
- name: Upload GitHub Release assets
if: github.event_name == 'push' && steps.stage.outputs.has_files == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VER: ${{ steps.ver.outputs.version }}
run: |
tag="v${VER}"
case "$VER" in
*-*) prerelease="--prerelease" ;;
*) prerelease="" ;;
esac
# Create the release if it doesn't exist yet; keep it if it does.
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 (e.g. after a notarization retry) overwrite.
gh release upload "$tag" dist/*.dmg dist/*.exe \
--repo "$GITHUB_REPOSITORY" --clobber