mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
build(desktop): decouple macOS notarization from CI into 3-stage release
This commit is contained in:
167
.github/workflows/publish-desktop.yml
vendored
Normal file
167
.github/workflows/publish-desktop.yml
vendored
Normal 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
|
||||||
88
.github/workflows/release.yml
vendored
88
.github/workflows/release.yml
vendored
@@ -1,9 +1,20 @@
|
|||||||
name: Release Desktop
|
name: Release Desktop
|
||||||
|
|
||||||
# Tag-driven release: push a tag like `v1.2.0` to build and publish the
|
# STAGE 1 of the decoupled release pipeline: BUILD ONLY.
|
||||||
# desktop client for macOS (arm64 + x64) and Windows (x64). The tag is the
|
# Builds the desktop client for macOS (arm64 + x64) and Windows (x64), mirrors
|
||||||
# single source of truth for the version — it's written into package.json at
|
# the installers to R2, and registers them in D1 as UNPUBLISHED (is_latest=0)
|
||||||
# build time, so the maintainer never edits the version by hand.
|
# 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:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
@@ -108,10 +119,6 @@ jobs:
|
|||||||
# is the correct state for unsigned builds.
|
# is the correct state for unsigned builds.
|
||||||
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||||
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
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_LINK: ${{ secrets.WIN_CSC_LINK }}
|
||||||
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
@@ -134,9 +141,12 @@ jobs:
|
|||||||
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
||||||
# `--publish never` still emits the latest*.yml files.
|
# `--publish never` still emits the latest*.yml files.
|
||||||
# Use the dynamic config (electron-builder.js): it populates
|
# Use the dynamic config (electron-builder.js): it populates
|
||||||
# mac.binaries (backend Mach-O files to sign) and enables built-in
|
# mac.binaries (backend Mach-O files to sign). Notarization is NOT done
|
||||||
# notarization, which zips the .app and submits that (the path Apple
|
# here — Apple's notary service keeps this large bundle "In Progress"
|
||||||
# actually accepts for this large bundle) then staples + packs the dmg.
|
# 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
|
npx electron-builder ${{ matrix.eb_flags }} --config electron-builder.js --publish never
|
||||||
|
|
||||||
# Upload artifacts regardless of outcome, so a failed run still surfaces
|
# 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.
|
# name (…-arm64.dmg / …-x64.dmg); .exe is the Windows installer.
|
||||||
sql_file="$(mktemp)"
|
sql_file="$(mktemp)"
|
||||||
|
|
||||||
# Pre-releases (e.g. 1.0.0-test / -beta / -rc.1 / -alpha / -dev) are
|
# This build job ALWAYS registers rows as unpublished (is_latest=0), so
|
||||||
# recorded but NEVER marked latest, so /download/<p>/latest keeps
|
# /download/<p>/latest keeps serving the previous release and the new
|
||||||
# serving the last stable build. They also must not clear an existing
|
# version stays invisible on the site. macOS dmgs still need to be
|
||||||
# stable's latest flag. Only a final version (no pre-release suffix)
|
# notarized+stapled locally (build/notarize-dmg.sh) before they're
|
||||||
# becomes the new latest and clears the previous one per platform.
|
# safe to ship. Promotion to latest happens later, only after
|
||||||
case "$VER" in
|
# notarization, via the separate "Publish Desktop" workflow.
|
||||||
*-*) is_latest=0; echo "==> $VER is a pre-release; not marking latest." ;;
|
echo "==> Registering $VER as unpublished (is_latest=0)."
|
||||||
*) is_latest=1; echo "==> $VER is a stable release; marking latest." ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Compute sha512 (base64, the format electron-updater validates) from
|
# Compute sha512 (base64, the format electron-updater validates) from
|
||||||
# the actual staged files rather than electron-builder's latest*.yml:
|
# the actual staged files rather than electron-builder's latest*.yml.
|
||||||
# stapling rewrites the dmg AFTER those yml files are generated, so
|
# NOTE: for macOS the dmg is re-hashed later by the publish workflow
|
||||||
# the yml hashes are stale for mac. Hashing the real bytes keeps the
|
# after stapling (stapling rewrites the dmg bytes), so the sha stored
|
||||||
# /update feed (generated from D1) consistent with what R2 serves.
|
# here is a placeholder for mac and authoritative only for win.
|
||||||
sha_for_file() { openssl dgst -sha512 -binary "$1" | openssl base64 -A; }
|
sha_for_file() { openssl dgst -sha512 -binary "$1" | openssl base64 -A; }
|
||||||
|
|
||||||
for f in dist/*; do
|
for f in dist/*; do
|
||||||
@@ -280,35 +288,9 @@ jobs:
|
|||||||
sha="$(sha_for_file "$f")"
|
sha="$(sha_for_file "$f")"
|
||||||
# SQL-escape single quotes defensively (base64 has none, but be safe).
|
# SQL-escape single quotes defensively (base64 has none, but be safe).
|
||||||
sha="${sha//\'/\'\'}"
|
sha="${sha//\'/\'\'}"
|
||||||
# Stable only: clear the previous latest for THIS platform first, so
|
# Never mark latest here — the row is unpublished until the separate
|
||||||
# a partial backfill never wipes other platforms' latest flag.
|
# publish workflow promotes it after notarization.
|
||||||
if [ "$is_latest" = "1" ]; then
|
echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, sha512, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, '${sha}', 0);" >> "$sql_file"
|
||||||
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"
|
|
||||||
done
|
done
|
||||||
echo "==> D1 statements:"; cat "$sql_file"
|
echo "==> D1 statements:"; cat "$sql_file"
|
||||||
npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$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
|
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -55,7 +55,7 @@ desktop/build/*
|
|||||||
!desktop/build/requirements-desktop.txt
|
!desktop/build/requirements-desktop.txt
|
||||||
!desktop/build/build-backend.sh
|
!desktop/build/build-backend.sh
|
||||||
!desktop/build/entitlements.mac.plist
|
!desktop/build/entitlements.mac.plist
|
||||||
!desktop/build/notarize-app.js
|
!desktop/build/notarize-dmg.sh
|
||||||
|
|
||||||
# Icon authoring scratch dir: intermediate assets used to produce the final
|
# Icon authoring scratch dir: intermediate assets used to produce the final
|
||||||
# icons. Only the finished icons under desktop/resources/ should be committed.
|
# icons. Only the finished icons under desktop/resources/ should be committed.
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
/**
|
|
||||||
* electron-builder afterSign hook: notarize the signed .app ourselves.
|
|
||||||
*
|
|
||||||
* Why not electron-builder's built-in notarize (mac.notarize):
|
|
||||||
* it runs `notarytool submit --wait`, which aborts the whole build on ANY
|
|
||||||
* network blip during status polling (NSURLErrorDomain -1001 timeout / -1009
|
|
||||||
* offline), with no retry — and on failure it re-submits a fresh upload,
|
|
||||||
* piling up duplicate submissions. We saw arm64 get "Accepted" yet the build
|
|
||||||
* still failed because the poll request dropped.
|
|
||||||
*
|
|
||||||
* This hook runs after signing and BEFORE the dmg is built, so we:
|
|
||||||
* 1. zip the .app with ditto (the payload Apple actually accepts for this
|
|
||||||
* large PyInstaller bundle — submitting the dmg got stuck In Progress),
|
|
||||||
* 2. `notarytool submit --no-wait` ONCE to get a submission id,
|
|
||||||
* 3. poll that SAME id until Accepted/Invalid — a failed poll (network) is
|
|
||||||
* ignored and retried; we never re-submit,
|
|
||||||
* 4. staple the ticket onto the .app; electron-builder then packs the dmg.
|
|
||||||
*
|
|
||||||
* Requires env: APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID.
|
|
||||||
* If they're absent (unsigned/dry build) the hook is a no-op.
|
|
||||||
* Tunables: NOTARIZE_MAX_WAIT_MINUTES (default 180), NOTARIZE_POLL_SECONDS (default 30).
|
|
||||||
*/
|
|
||||||
const { execFileSync } = require('child_process')
|
|
||||||
const fs = require('fs')
|
|
||||||
const os = require('os')
|
|
||||||
const path = require('path')
|
|
||||||
|
|
||||||
function sh(cmd, args, opts = {}) {
|
|
||||||
return execFileSync(cmd, args, { encoding: 'utf8', ...opts })
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.default = async function notarizeApp(context) {
|
|
||||||
const { electronPlatformName, appOutDir, packager } = context
|
|
||||||
if (electronPlatformName !== 'darwin') return
|
|
||||||
|
|
||||||
const appleId = process.env.APPLE_ID
|
|
||||||
const applePassword = process.env.APPLE_APP_SPECIFIC_PASSWORD
|
|
||||||
const teamId = process.env.APPLE_TEAM_ID
|
|
||||||
if (!appleId || !applePassword || !teamId) {
|
|
||||||
console.log('[notarize-app] APPLE_* env not set — skipping notarization')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const appName = packager.appInfo.productFilename
|
|
||||||
const appPath = path.join(appOutDir, `${appName}.app`)
|
|
||||||
if (!fs.existsSync(appPath)) {
|
|
||||||
console.log(`[notarize-app] no .app at ${appPath}, skipping`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = [
|
|
||||||
'--apple-id', appleId,
|
|
||||||
'--password', applePassword,
|
|
||||||
'--team-id', teamId,
|
|
||||||
]
|
|
||||||
|
|
||||||
const maxWaitMin = parseInt(process.env.NOTARIZE_MAX_WAIT_MINUTES || '180', 10)
|
|
||||||
const pollSec = parseInt(process.env.NOTARIZE_POLL_SECONDS || '30', 10)
|
|
||||||
|
|
||||||
// 1) zip the .app (ditto preserves symlinks/metadata; --keepParent keeps .app).
|
|
||||||
const zipPath = path.join(os.tmpdir(), `${appName}-${context.arch}-notarize.zip`)
|
|
||||||
console.log(`[notarize-app] zipping ${appPath} -> ${zipPath}`)
|
|
||||||
sh('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath], { stdio: 'inherit' })
|
|
||||||
|
|
||||||
// 2) submit ONCE, no --wait, capture the submission id.
|
|
||||||
console.log('[notarize-app] submitting to notary service (no-wait)...')
|
|
||||||
const submitOut = sh('xcrun', ['notarytool', 'submit', zipPath, ...auth, '--no-wait', '--output-format', 'json'])
|
|
||||||
let submissionId
|
|
||||||
try {
|
|
||||||
submissionId = JSON.parse(submitOut).id
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`[notarize-app] could not parse submission id:\n${submitOut}`)
|
|
||||||
}
|
|
||||||
if (!submissionId) throw new Error(`[notarize-app] empty submission id:\n${submitOut}`)
|
|
||||||
console.log(`[notarize-app] submission id: ${submissionId} (polling same id, never resubmitting)`)
|
|
||||||
|
|
||||||
// 3) poll the SAME id; a failed poll (network) is ignored and retried.
|
|
||||||
const deadline = Date.now() + maxWaitMin * 60 * 1000
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
||||||
for (;;) {
|
|
||||||
let status = ''
|
|
||||||
try {
|
|
||||||
const infoOut = sh('xcrun', ['notarytool', 'info', submissionId, ...auth, '--output-format', 'json'])
|
|
||||||
status = JSON.parse(infoOut).status || ''
|
|
||||||
} catch (e) {
|
|
||||||
status = '' // treat query failure as unknown; keep polling
|
|
||||||
}
|
|
||||||
const ts = new Date().toISOString().slice(11, 19)
|
|
||||||
console.log(`[notarize-app] [${ts}] status: ${status || '<query failed, retrying>'}`)
|
|
||||||
|
|
||||||
if (status === 'Accepted') break
|
|
||||||
if (status === 'Invalid' || status === 'Rejected') {
|
|
||||||
try {
|
|
||||||
console.log(sh('xcrun', ['notarytool', 'log', submissionId, ...auth]))
|
|
||||||
} catch {}
|
|
||||||
throw new Error(`[notarize-app] notarization ${status} (id: ${submissionId})`)
|
|
||||||
}
|
|
||||||
if (Date.now() >= deadline) {
|
|
||||||
throw new Error(
|
|
||||||
`[notarize-app] not finished after ${maxWaitMin} min (id: ${submissionId}). ` +
|
|
||||||
`NOT resubmitting. Check later: xcrun notarytool info ${submissionId}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
await sleep(pollSec * 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) staple the ticket onto the .app (dmg is built afterwards by electron-builder).
|
|
||||||
console.log('[notarize-app] Accepted; stapling ticket to .app')
|
|
||||||
let stapleTry = 1
|
|
||||||
for (;;) {
|
|
||||||
try {
|
|
||||||
sh('xcrun', ['stapler', 'staple', appPath], { stdio: 'inherit' })
|
|
||||||
break
|
|
||||||
} catch (e) {
|
|
||||||
if (stapleTry >= 3) throw e
|
|
||||||
console.log('[notarize-app] staple failed, retrying in 15s...')
|
|
||||||
await sleep(15000)
|
|
||||||
stapleTry++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try { fs.unlinkSync(zipPath) } catch {}
|
|
||||||
console.log('[notarize-app] notarization + staple complete')
|
|
||||||
}
|
|
||||||
147
desktop/build/notarize-dmg.sh
Executable file
147
desktop/build/notarize-dmg.sh
Executable file
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# STAGE 2 of the decoupled release pipeline: notarize signed dmgs locally.
|
||||||
|
#
|
||||||
|
# CI (stage 1) already produced code-signed, hardened-runtime dmgs and mirrored
|
||||||
|
# them to R2 as unpublished. Apple's notary service keeps this large PyInstaller
|
||||||
|
# bundle "In Progress" for hours, so we notarize here — off the CI clock — and
|
||||||
|
# staple the ticket straight onto the dmg (users download it ready-to-run).
|
||||||
|
#
|
||||||
|
# What it does for each dmg passed on the command line:
|
||||||
|
# 1. submit the dmg to the notary service ONCE (--no-wait) and remember the id,
|
||||||
|
# 2. poll that SAME id until Accepted/Invalid; network errors are ignored and
|
||||||
|
# retried, and it NEVER resubmits (avoids piling up duplicate submissions),
|
||||||
|
# 3. staple the ticket onto the dmg,
|
||||||
|
# 4. (optional) re-upload the stapled dmg to R2, overwriting the unpublished
|
||||||
|
# copy, so the CDN serves the notarized bytes.
|
||||||
|
#
|
||||||
|
# Auth: uses a stored keychain profile (default: cow-notary). Create it once via
|
||||||
|
# xcrun notarytool store-credentials cow-notary \
|
||||||
|
# --apple-id <id> --team-id <team> --password <app-specific-password>
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# # notarize + staple only (no upload):
|
||||||
|
# desktop/build/notarize-dmg.sh path/to/CowAgent-1.2.3-arm64.dmg [more.dmg ...]
|
||||||
|
#
|
||||||
|
# # notarize + staple + re-upload to R2 (needs wrangler + Cloudflare creds):
|
||||||
|
# VER=1.2.3 UPLOAD=1 desktop/build/notarize-dmg.sh *.dmg
|
||||||
|
#
|
||||||
|
# Env:
|
||||||
|
# PROFILE keychain profile name (default: cow-notary)
|
||||||
|
# UPLOAD set to 1 to re-upload stapled dmgs to R2
|
||||||
|
# VER version string for the R2 key desktop/v${VER}/<file> (required if UPLOAD=1)
|
||||||
|
# R2_BUCKET R2 bucket (default: cow-skills)
|
||||||
|
# POLL_SECONDS status poll interval (default: 60)
|
||||||
|
# MAX_WAIT_MINUTES give up polling after this long (default: 720 = 12h)
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROFILE="${PROFILE:-cow-notary}"
|
||||||
|
R2_BUCKET="${R2_BUCKET:-cow-skills}"
|
||||||
|
POLL_SECONDS="${POLL_SECONDS:-60}"
|
||||||
|
MAX_WAIT_MINUTES="${MAX_WAIT_MINUTES:-720}"
|
||||||
|
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
echo "usage: $0 <dmg> [dmg ...]" >&2
|
||||||
|
echo " set UPLOAD=1 and VER=<version> to also re-upload stapled dmgs to R2" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${UPLOAD:-0}" = "1" ] && [ -z "${VER:-}" ]; then
|
||||||
|
echo "error: UPLOAD=1 requires VER=<version> (used for the R2 key)" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
log() { echo "[notarize-dmg] $*"; }
|
||||||
|
|
||||||
|
notarize_one() {
|
||||||
|
local dmg="$1"
|
||||||
|
if [ ! -f "$dmg" ]; then
|
||||||
|
log "SKIP: not a file: $dmg"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If it's already stapled (e.g. re-run), skip straight to (optional) upload.
|
||||||
|
if xcrun stapler validate "$dmg" >/dev/null 2>&1; then
|
||||||
|
log "$dmg already stapled — skipping notarization."
|
||||||
|
else
|
||||||
|
log "submitting $dmg (no-wait)..."
|
||||||
|
local submit_out submission_id
|
||||||
|
submit_out="$(xcrun notarytool submit "$dmg" \
|
||||||
|
--keychain-profile "$PROFILE" --no-wait --output-format json)"
|
||||||
|
submission_id="$(echo "$submit_out" | /usr/bin/plutil -extract id raw - 2>/dev/null || true)"
|
||||||
|
if [ -z "$submission_id" ] || [ "$submission_id" = "null" ]; then
|
||||||
|
# Fallback parse without plutil (json is single-line).
|
||||||
|
submission_id="$(echo "$submit_out" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')"
|
||||||
|
fi
|
||||||
|
if [ -z "$submission_id" ]; then
|
||||||
|
log "ERROR: could not parse submission id from:"
|
||||||
|
echo "$submit_out" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log "submission id: $submission_id (polling same id, never resubmitting)"
|
||||||
|
|
||||||
|
local deadline status ts
|
||||||
|
deadline=$(( $(date +%s) + MAX_WAIT_MINUTES * 60 ))
|
||||||
|
while :; do
|
||||||
|
status=""
|
||||||
|
status="$(xcrun notarytool info "$submission_id" \
|
||||||
|
--keychain-profile "$PROFILE" --output-format json 2>/dev/null \
|
||||||
|
| sed -n 's/.*"status":"\([^"]*\)".*/\1/p' || true)"
|
||||||
|
ts="$(date +%H:%M:%S)"
|
||||||
|
log "[$ts] status: ${status:-<query failed, retrying>}"
|
||||||
|
|
||||||
|
case "$status" in
|
||||||
|
Accepted) break ;;
|
||||||
|
Invalid|Rejected)
|
||||||
|
log "notarization $status — fetching log:"
|
||||||
|
xcrun notarytool log "$submission_id" --keychain-profile "$PROFILE" || true
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||||
|
log "ERROR: not finished after ${MAX_WAIT_MINUTES} min (id: $submission_id)."
|
||||||
|
log "NOT resubmitting. Check later: xcrun notarytool info $submission_id --keychain-profile $PROFILE"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sleep "$POLL_SECONDS"
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Accepted; stapling ticket to $dmg"
|
||||||
|
local staple_try=1
|
||||||
|
until xcrun stapler staple "$dmg"; do
|
||||||
|
if [ "$staple_try" -ge 3 ]; then
|
||||||
|
log "ERROR: stapling failed after 3 attempts"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log "staple failed, retrying in 15s..."
|
||||||
|
sleep 15
|
||||||
|
staple_try=$((staple_try + 1))
|
||||||
|
done
|
||||||
|
xcrun stapler validate "$dmg"
|
||||||
|
log "$dmg notarized + stapled."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${UPLOAD:-0}" = "1" ]; then
|
||||||
|
local base key
|
||||||
|
base="$(basename "$dmg")"
|
||||||
|
key="desktop/v${VER}/${base}"
|
||||||
|
log "re-uploading stapled dmg -> r2://${R2_BUCKET}/${key}"
|
||||||
|
npx --yes wrangler@latest r2 object put "${R2_BUCKET}/${key}" \
|
||||||
|
--file "$dmg" --remote
|
||||||
|
log "uploaded $base"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
rc=0
|
||||||
|
for dmg in "$@"; do
|
||||||
|
echo "======================================================================"
|
||||||
|
notarize_one "$dmg" || rc=1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
log "one or more dmgs failed — see output above."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "all done."
|
||||||
@@ -71,15 +71,13 @@ function collectBackendBinaries() {
|
|||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
const binaries = collectBackendBinaries()
|
const binaries = collectBackendBinaries()
|
||||||
console.log(`[electron-builder.js] injecting ${binaries.length} backend binaries into mac.binaries`)
|
console.log(`[electron-builder.js] injecting ${binaries.length} backend binaries into mac.binaries`)
|
||||||
// Disable the built-in notarize: it runs `notarytool submit --wait`, which
|
// Sign the backend binaries here, but do NOT notarize in CI: Apple's notary
|
||||||
// aborts the whole build on any network blip during polling (-1001/-1009)
|
// service routinely keeps this large PyInstaller bundle "In Progress" for
|
||||||
// and re-submits fresh uploads on failure. Instead we notarize in an
|
// hours, which no CI job can afford to block on. Notarization is decoupled
|
||||||
// afterSign hook (build/notarize-app.js) that zips the .app (the payload
|
// into a manual local step (build/notarize-dmg.sh) run after the CI produces
|
||||||
// Apple accepts for this bundle), submits ONCE, and polls the same id with
|
// the signed dmg. The dmg is code-signed and hardened-runtime enabled here,
|
||||||
// network-fault tolerance. The hook runs after signing, before the dmg is
|
// so it only needs the notarization ticket stapled afterwards.
|
||||||
// built, and staples the .app so the dmg ships an offline-valid ticket.
|
|
||||||
config.mac = { ...config.mac, binaries, notarize: false }
|
config.mac = { ...config.mac, binaries, notarize: false }
|
||||||
config.afterSign = 'build/notarize-app.js'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = config
|
module.exports = config
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data: blob: http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob: http://127.0.0.1:* http://localhost:*; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*;" />
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data: blob: http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob: https: http://127.0.0.1:* http://localhost:*; media-src 'self' data: blob: https: http://127.0.0.1:* http://localhost:*; connect-src 'self' https: http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*;" />
|
||||||
<title>CowAgent</title>
|
<title>CowAgent</title>
|
||||||
<!-- Local fonts & icons (offline, no CDN) served from publicDir -->
|
<!-- Local fonts & icons (offline, no CDN) served from publicDir -->
|
||||||
<link rel="stylesheet" href="./vendor/fonts/inter/inter.css" />
|
<link rel="stylesheet" href="./vendor/fonts/inter/inter.css" />
|
||||||
|
|||||||
Reference in New Issue
Block a user