feat(desktop): mac zip auto-update

This commit is contained in:
zhayujie
2026-07-07 15:11:55 +08:00
parent 8df38a23d2
commit d531e14fbf
11 changed files with 254 additions and 80 deletions

116
.github/scripts/register-releases.mjs vendored Normal file
View File

@@ -0,0 +1,116 @@
// Build the D1 upsert SQL for a desktop release from the files in a directory.
//
// Each mac release has TWO artifacts that map to a SINGLE D1 row:
// - <name>-<arch>.dmg -> manual download (filename / size / sha512)
// - <name>-<arch>.zip -> auto-update (update_filename / update_size /
// update_sha512)
// electron-updater's MacUpdater can only consume a zip, never a dmg, so the
// feed serves the zip while the website serves the dmg. Windows has only the
// .exe (stored in the main columns; it's both the download and the update).
//
// We emit ONE `INSERT OR REPLACE` per (version, platform) carrying BOTH halves,
// because two replaces on the same primary key would drop whichever came first.
//
// Usage:
// node register-releases.mjs --dir dist --version 1.2.0 \
// --sql out.sql [--latest]
//
// --latest mark these rows is_latest=1 AND clear the previous latest for
// each platform (used by the publish/promote workflow). Without it
// rows are written unpublished (is_latest=0) — the build stage.
//
// sha512 is base64 (the exact format electron-updater validates).
import { execSync } from 'node:child_process'
import fs from 'node:fs'
function arg(name, fallback = undefined) {
const i = process.argv.indexOf(`--${name}`)
if (i === -1) return fallback
const next = process.argv[i + 1]
// Boolean flag (no value or next token is another flag).
if (next === undefined || next.startsWith('--')) return true
return next
}
const dir = arg('dir', 'dist')
const version = arg('version')
const sqlPath = arg('sql', 'd1.sql')
const makeLatest = arg('latest', false) === true
if (!version) {
console.error('register-releases: --version is required')
process.exit(1)
}
const sha512 = (f) =>
execSync(`openssl dgst -sha512 -binary "${f}" | openssl base64 -A`, {
shell: '/bin/bash',
})
.toString()
.trim()
// SQL-escape single quotes (base64/keys shouldn't contain them, but be safe).
const q = (s) => String(s).replace(/'/g, "''")
// platform -> { main: {key,size,sha}, upd: {key,size,sha} }
const rows = {}
for (const base of fs.readdirSync(dir)) {
const f = `${dir}/${base}`
if (fs.statSync(f).isDirectory()) continue
let platform
let slot
if (/arm64\.dmg$/.test(base)) {
platform = 'mac-arm64'
slot = 'main'
} else if (/x64\.dmg$/.test(base)) {
platform = 'mac-x64'
slot = 'main'
} else if (/arm64\.zip$/.test(base)) {
platform = 'mac-arm64'
slot = 'upd'
} else if (/x64\.zip$/.test(base)) {
platform = 'mac-x64'
slot = 'upd'
} else if (/\.exe$/.test(base)) {
platform = 'win'
slot = 'main'
} else {
console.log('Skipping unrecognized artifact:', base)
continue
}
rows[platform] ||= {}
rows[platform][slot] = {
key: `v${version}/${base}`,
size: fs.statSync(f).size,
sha: sha512(f),
}
}
if (Object.keys(rows).length === 0) {
console.error('register-releases: no recognized artifacts in', dir)
process.exit(1)
}
const isLatest = makeLatest ? 1 : 0
const sql = []
for (const [platform, r] of Object.entries(rows)) {
const m = r.main || { key: '', size: 0, sha: '' }
const u = r.upd || { key: '', size: 0, sha: '' }
if (makeLatest) {
// Clear the previous latest for this platform before promoting the new row.
sql.push(`UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';`)
}
sql.push(
`INSERT OR REPLACE INTO releases ` +
`(version, platform, filename, size, sha512, update_filename, update_size, update_sha512, is_latest) ` +
`VALUES ('${version}', '${platform}', '${q(m.key)}', ${m.size}, '${q(m.sha)}', ` +
`'${q(u.key)}', ${u.size}, '${q(u.sha)}', ${isLatest});`
)
}
fs.writeFileSync(sqlPath, sql.join('\n') + '\n')
console.log(`register-releases: wrote ${sql.length} statement(s) to ${sqlPath}`)

View File

@@ -62,7 +62,7 @@ jobs:
# 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}';")"
--command "SELECT platform, filename, update_filename FROM releases WHERE version = '${VER}';")"
echo "$out"
echo "$out" | node -e '
const fs = require("fs");
@@ -84,12 +84,14 @@ jobs:
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}" \
# Pull BOTH the manual-download file (filename: dmg/exe) and the mac
# auto-update file (update_filename: zip) for every row. Keys are
# "v<VER>/<base>"; the R2 key is "desktop/<key>".
for key in $(node -e 'JSON.parse(require("fs").readFileSync("rows.json")).forEach(r => { if (r.filename) console.log(r.filename); if (r.update_filename) console.log(r.update_filename); })'); do
base="$(basename "$key")"
r2key="desktop/${key}"
echo "==> Downloading r2://${R2_BUCKET}/${r2key} -> dist/${base}"
npx --yes wrangler@latest r2 object get "${R2_BUCKET}/${r2key}" \
--file "dist/${base}" --remote
done
echo "Downloaded:"; ls -la dist
@@ -110,9 +112,6 @@ jobs:
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
@@ -120,32 +119,17 @@ jobs:
*) is_pre=0 ;;
esac
if [ "$MAKE_LATEST" = "true" ] && [ "$is_pre" = "0" ]; then
do_latest=1; echo "==> Publishing $VER as latest."
latest_flag="--latest"; echo "==> Publishing $VER as latest."
else
do_latest=0; echo "==> Publishing $VER without latest flag (pre-release or dry re-hash)."
latest_flag=""; 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"
# Re-hash the real (stapled) bytes and re-store every row with both the
# dmg (manual) and mac zip (auto-update) columns. Same script as the
# build stage; --latest also clears the previous latest per platform.
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql $latest_flag
echo "==> D1 statements:"; cat d1.sql
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
- name: Create/update GitHub Release and attach installers
if: github.event.inputs.github_release == 'true'
@@ -162,6 +146,9 @@ jobs:
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 \
# --clobber so re-runs overwrite the stapled/updated assets. The mac
# zip is the auto-update artifact; attach it too so the GitHub Release
# is a complete mirror (nullglob avoids errors when a type is absent).
shopt -s nullglob
gh release upload "$tag" dist/*.dmg dist/*.zip dist/*.exe \
--repo "$GITHUB_REPOSITORY" --clobber

View File

@@ -246,7 +246,9 @@ jobs:
# 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.
find artifacts -type f \( -name '*.dmg' -o -name '*.exe' -o -name '*.blockmap' \) -exec cp {} dist/ \;
# .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.
@@ -281,10 +283,6 @@ jobs:
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)"
# 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
@@ -293,29 +291,10 @@ jobs:
# 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.
# 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
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")"
# SQL-escape single quotes defensively (base64 has none, but be safe).
sha="${sha//\'/\'\'}"
# 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"
# 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