cc-haha/.github/workflows/release-desktop.yml
程序员阿江(Relakkes) f9b48a3031 feat(release): allow signed-only macOS draft builds
Add a workflow_dispatch-only notarize_macos switch so draft release runs can build Developer ID signed macOS artifacts without waiting on Apple notarization when GitHub runner networking is failing. Tag push releases still default to notarization and Gatekeeper release checks.\n\nTested: bun test scripts/pr/release-workflow.test.ts\nTested: git diff --check\nTested: bun run scripts/release.ts 0.4.3 --dry\nConfidence: medium\nScope-risk: moderate
2026-06-17 04:47:52 +08:00

507 lines
20 KiB
YAML

name: Release Desktop
on:
push:
tags: ['v*.*.*']
workflow_dispatch:
inputs:
draft:
description: 'Create as draft release'
required: false
default: true
type: boolean
notarize_macos:
description: 'Notarize macOS artifacts'
required: false
default: true
type: boolean
permissions:
contents: write
concurrency:
group: release-desktop-${{ github.ref }}
cancel-in-progress: true
jobs:
signing-preflight:
runs-on: ubuntu-latest
outputs:
macos_signed: ${{ steps.validate.outputs.macos_signed }}
steps:
- name: Validate release signing and notarization secrets
id: validate
shell: bash
env:
CSC_LINK: ${{ secrets.MACOS_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_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.WINDOWS_CERTIFICATE }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
run: |
# macOS signing + notarization is preferred: Squirrel.Mac auto-update and
# first-launch Gatekeeper approval work best with a notarized Developer ID build.
# A migration release may still ship unsigned while Apple Developer ID setup is pending.
missing=()
[ -n "$CSC_LINK" ] || missing+=("MACOS_CERTIFICATE")
[ -n "$CSC_KEY_PASSWORD" ] || missing+=("MACOS_CERTIFICATE_PASSWORD")
[ -n "$APPLE_ID" ] || missing+=("APPLE_ID")
[ -n "$APPLE_APP_SPECIFIC_PASSWORD" ] || missing+=("APPLE_APP_SPECIFIC_PASSWORD")
[ -n "$APPLE_TEAM_ID" ] || missing+=("APPLE_TEAM_ID")
if [ "${#missing[@]}" -gt 0 ]; then
printf '::warning::Missing macOS signing/notarization secrets (%s): macOS artifacts will be unsigned and users must use install-macos-unsigned.sh.\n' "${missing[*]}"
echo "macos_signed=false" >> "$GITHUB_OUTPUT"
else
echo "macos_signed=true" >> "$GITHUB_OUTPUT"
fi
# Windows signing is optional: an unsigned NSIS installer still auto-updates
# (electron-updater), it only triggers SmartScreen warnings. Warn, do not block,
# so releases can ship with an Apple Developer ID alone.
win_missing=()
[ -n "$WIN_CSC_LINK" ] || win_missing+=("WINDOWS_CERTIFICATE")
[ -n "$WIN_CSC_KEY_PASSWORD" ] || win_missing+=("WINDOWS_CERTIFICATE_PASSWORD")
if [ "${#win_missing[@]}" -gt 0 ]; then
printf '::warning::Windows signing secrets missing (%s): the Windows build will be unsigned. Auto-update still works, but users will see SmartScreen warnings.\n' "${win_missing[*]}"
fi
build:
needs:
- signing-preflight
strategy:
fail-fast: false
matrix:
include:
- platform: macos-latest
target_triple: aarch64-apple-darwin
builder_args: --mac dmg zip --arm64
label: macOS-ARM64
smoke_platform: macos
- platform: macos-latest
target_triple: x86_64-apple-darwin
builder_args: --mac dmg zip --x64
label: macOS-x64
smoke_platform: macos
- platform: ubuntu-22.04
target_triple: x86_64-unknown-linux-gnu
builder_args: --linux AppImage deb --x64
label: Linux-x64
smoke_platform: linux
- platform: ubuntu-22.04-arm
target_triple: aarch64-unknown-linux-gnu
builder_args: --linux AppImage deb --arm64
label: Linux-ARM64
smoke_platform: linux
- platform: windows-latest
target_triple: x86_64-pc-windows-msvc
builder_args: --win nsis --x64
label: Windows-x64
smoke_platform: windows
runs-on: ${{ matrix.platform }}
name: Build (${{ matrix.label }})
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Read version
id: version
shell: bash
run: |
VERSION=$(node -p "require('./desktop/package.json').version")
echo "value=$VERSION" >> "$GITHUB_OUTPUT"
- name: Validate tag matches version
if: github.event_name == 'push'
shell: bash
run: |
EXPECTED_TAG="v${{ steps.version.outputs.value }}"
if [ "${GITHUB_REF_NAME}" != "$EXPECTED_TAG" ]; then
echo "::error::Tag ${GITHUB_REF_NAME} does not match app version ${EXPECTED_TAG}"
exit 1
fi
- name: Install Linux dependencies
if: contains(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y build-essential curl wget file libfuse2
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install root dependencies
run: bun install
- name: Install desktop dependencies
working-directory: desktop
run: bun install
- name: Install adapter dependencies
working-directory: adapters
run: bun install
- name: Build sidecars
working-directory: desktop
env:
BUN_INSTALL_CACHE_DIR: ${{ runner.temp }}/bun-install-cache
SIDECAR_TARGET_TRIPLE: ${{ matrix.target_triple }}
run: bun run build:sidecars
- name: Build renderer and Electron bundles
working-directory: desktop
run: |
bun run build
bun run build:electron
- name: Build signed macOS Electron release artifacts
if: matrix.smoke_platform == 'macos' && needs.signing-preflight.outputs.macos_signed == 'true'
working-directory: desktop
timeout-minutes: 80
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEBUG: 'electron-builder,electron-osx-sign,electron-notarize*'
# Signed macOS releases require all of these secrets. Keep this step
# separate from the unsigned fallback so empty secrets are never passed
# to electron-builder as CSC_LINK / APPLE_* env vars.
CSC_LINK: ${{ secrets.MACOS_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
MACOS_NOTARIZE: ${{ github.event_name != 'workflow_dispatch' || inputs.notarize_macos }}
run: |
set -euo pipefail
echo "::group::macOS signing diagnostics"
echo "UTC start: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
sw_vers
xcodebuild -version
xcrun --find notarytool
xcrun notarytool --version || true
security find-identity -v -p codesigning || true
echo "macOS notarization requested: ${MACOS_NOTARIZE}"
echo "::endgroup::"
if [ "$MACOS_NOTARIZE" = "true" ]; then
max_attempts=2
build_timeout_seconds=2100
builder_args=( ${{ matrix.builder_args }} --publish never )
else
max_attempts=1
build_timeout_seconds=900
builder_args=( ${{ matrix.builder_args }} --publish never -c.mac.notarize=false )
echo "::warning::macOS notarization is disabled for this draft run; artifacts will be Developer ID signed but not notarized."
fi
run_signed_electron_builder() {
local timeout_seconds="$1"
shift
"$@" &
local build_pid=$!
local start_epoch
start_epoch=$(date +%s)
while true; do
if ! jobs -pr | grep -q "^${build_pid}$"; then
break
fi
local now_epoch elapsed_seconds
now_epoch=$(date +%s)
elapsed_seconds=$((now_epoch - start_epoch))
if [ "$elapsed_seconds" -ge "$timeout_seconds" ]; then
echo "::warning::Signed electron-builder timed out after ${elapsed_seconds}s; terminating process ${build_pid}"
pkill -TERM -P "$build_pid" 2>/dev/null || true
kill -TERM "$build_pid" 2>/dev/null || true
sleep 10
pkill -KILL -P "$build_pid" 2>/dev/null || true
kill -KILL "$build_pid" 2>/dev/null || true
wait "$build_pid" 2>/dev/null || true
return 124
fi
sleep 15
done
wait "$build_pid"
}
for attempt in $(seq 1 "$max_attempts"); do
echo "Starting signed electron-builder attempt ${attempt}/${max_attempts} at $(date -u '+%Y-%m-%dT%H:%M:%SZ') with ${build_timeout_seconds}s watchdog"
rm -rf build-artifacts/electron
set +e
run_signed_electron_builder "$build_timeout_seconds" node ./node_modules/electron-builder/out/cli/cli.js "${builder_args[@]}"
status=$?
set -e
if [ "$status" -eq 0 ]; then
echo "Finished signed electron-builder attempt ${attempt}/${max_attempts} at $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
exit 0
fi
if [ "$attempt" -eq "$max_attempts" ]; then
echo "::error::Signed electron-builder failed after ${max_attempts} attempts"
exit "$status"
fi
echo "::warning::Signed electron-builder attempt ${attempt}/${max_attempts} failed with exit code ${status}; retrying after 120 seconds"
sleep 120
done
- name: Build unsigned Electron release artifacts
if: matrix.smoke_platform != 'macos' || needs.signing-preflight.outputs.macos_signed != 'true'
working-directory: desktop
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Unsigned fallback: do not pass CSC_LINK / APPLE_* / WIN_CSC_* here.
# Empty secrets are rendered as empty strings, and electron-builder
# treats an empty CSC_LINK key as an explicit certificate path.
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: node ./node_modules/electron-builder/out/cli/cli.js ${{ matrix.builder_args }} --publish never
- name: Verify packaged app structure
run: bun run test:package-smoke --platform ${{ matrix.smoke_platform }} --package-kind release --artifacts-dir desktop/build-artifacts/electron
- name: Verify macOS launch policy
if: matrix.smoke_platform == 'macos' && needs.signing-preflight.outputs.macos_signed == 'true' && (github.event_name != 'workflow_dispatch' || inputs.notarize_macos == true)
run: bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/electron --require-macos-gatekeeper
- name: Warn macOS notarization skipped
if: matrix.smoke_platform == 'macos' && needs.signing-preflight.outputs.macos_signed == 'true' && github.event_name == 'workflow_dispatch' && inputs.notarize_macos == false
run: echo "::warning::Skipping macOS Gatekeeper package-smoke because notarization is disabled for this draft. Artifacts are Developer ID signed but not notarized."
- name: Warn unsigned macOS launch policy skipped
if: matrix.smoke_platform == 'macos' && needs.signing-preflight.outputs.macos_signed != 'true'
run: echo "::warning::Skipping macOS Gatekeeper package-smoke because this release is unsigned. Ship install-macos-unsigned.sh with the DMG."
- name: Validate matrix release asset set
shell: bash
working-directory: desktop/build-artifacts/electron
env:
APP_VERSION: ${{ steps.version.outputs.value }}
run: |
case "${{ matrix.label }}" in
macOS-ARM64)
expected=(
"Claude-Code-Haha-${APP_VERSION}-mac-arm64.dmg"
"Claude-Code-Haha-${APP_VERSION}-mac-arm64.dmg.blockmap"
"Claude-Code-Haha-${APP_VERSION}-mac-arm64.zip"
"Claude-Code-Haha-${APP_VERSION}-mac-arm64.zip.blockmap"
"latest-mac.yml"
)
;;
macOS-x64)
expected=(
"Claude-Code-Haha-${APP_VERSION}-mac-x64.dmg"
"Claude-Code-Haha-${APP_VERSION}-mac-x64.dmg.blockmap"
"Claude-Code-Haha-${APP_VERSION}-mac-x64.zip"
"Claude-Code-Haha-${APP_VERSION}-mac-x64.zip.blockmap"
"latest-mac.yml"
)
;;
Linux-x64)
expected=(
"Claude-Code-Haha-${APP_VERSION}-linux-x86_64.AppImage"
"Claude-Code-Haha-${APP_VERSION}-linux-amd64.deb"
"latest-linux.yml"
)
;;
Linux-ARM64)
expected=(
"Claude-Code-Haha-${APP_VERSION}-linux-arm64.AppImage"
"Claude-Code-Haha-${APP_VERSION}-linux-arm64.deb"
"latest-linux-arm64.yml"
)
;;
Windows-x64)
expected=(
"Claude-Code-Haha-${APP_VERSION}-win-x64.exe"
"Claude-Code-Haha-${APP_VERSION}-win-x64.exe.blockmap"
"latest.yml"
)
;;
*)
echo "::error::No expected asset list for matrix label ${{ matrix.label }}"
exit 1
;;
esac
missing=()
for file in "${expected[@]}"; do
[ -f "$file" ] || missing+=("$file")
done
if [ "${#missing[@]}" -gt 0 ]; then
printf '::error::Missing release assets for %s: %s\n' "${{ matrix.label }}" "${missing[*]}"
exit 1
fi
- name: Namespace update metadata assets
shell: bash
working-directory: desktop/build-artifacts/electron
run: |
shopt -s nullglob
for file in latest*.yml; do
mv "$file" "${file%.yml}-${{ matrix.label }}.yml"
done
- name: Upload update metadata for merge
uses: actions/upload-artifact@v4
with:
name: desktop-update-metadata-${{ matrix.label }}
path: desktop/build-artifacts/electron/latest*.yml
if-no-files-found: ignore
- name: Upload release artifacts for final publish
uses: actions/upload-artifact@v4
with:
name: desktop-release-artifacts-${{ matrix.label }}
path: |
desktop/build-artifacts/electron/*.dmg
desktop/build-artifacts/electron/*.zip
desktop/build-artifacts/electron/*.exe
desktop/build-artifacts/electron/*.AppImage
desktop/build-artifacts/electron/*.deb
desktop/build-artifacts/electron/*.blockmap
desktop/build-artifacts/electron/*.yml
if-no-files-found: error
publish-release:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Read version
id: version
shell: bash
run: |
VERSION=$(node -p "require('./desktop/package.json').version")
echo "value=$VERSION" >> "$GITHUB_OUTPUT"
- name: Validate tag matches version
if: github.event_name == 'push'
shell: bash
run: |
EXPECTED_TAG="v${{ steps.version.outputs.value }}"
if [ "${GITHUB_REF_NAME}" != "$EXPECTED_TAG" ]; then
echo "::error::Tag ${GITHUB_REF_NAME} does not match app version ${EXPECTED_TAG}"
exit 1
fi
- name: Load release notes
id: release_notes
shell: bash
run: |
NOTES_FILE="release-notes/v${{ steps.version.outputs.value }}.md"
if [ ! -f "$NOTES_FILE" ]; then
echo "::error::Missing release notes file: $NOTES_FILE"
exit 1
fi
{
echo 'body<<__RELEASE_NOTES__'
cat "$NOTES_FILE"
echo
echo '__RELEASE_NOTES__'
} >> "$GITHUB_OUTPUT"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install root dependencies
run: bun install
- name: Download release artifacts
uses: actions/download-artifact@v4
with:
pattern: desktop-release-artifacts-*
path: artifacts/release-assets
merge-multiple: true
- name: Validate complete release asset set
shell: bash
env:
APP_VERSION: ${{ steps.version.outputs.value }}
run: |
expected=(
"Claude-Code-Haha-${APP_VERSION}-mac-arm64.dmg"
"Claude-Code-Haha-${APP_VERSION}-mac-arm64.dmg.blockmap"
"Claude-Code-Haha-${APP_VERSION}-mac-arm64.zip"
"Claude-Code-Haha-${APP_VERSION}-mac-arm64.zip.blockmap"
"Claude-Code-Haha-${APP_VERSION}-mac-x64.dmg"
"Claude-Code-Haha-${APP_VERSION}-mac-x64.dmg.blockmap"
"Claude-Code-Haha-${APP_VERSION}-mac-x64.zip"
"Claude-Code-Haha-${APP_VERSION}-mac-x64.zip.blockmap"
"Claude-Code-Haha-${APP_VERSION}-linux-x86_64.AppImage"
"Claude-Code-Haha-${APP_VERSION}-linux-amd64.deb"
"Claude-Code-Haha-${APP_VERSION}-linux-arm64.AppImage"
"Claude-Code-Haha-${APP_VERSION}-linux-arm64.deb"
"Claude-Code-Haha-${APP_VERSION}-win-x64.exe"
"Claude-Code-Haha-${APP_VERSION}-win-x64.exe.blockmap"
)
missing=()
for file in "${expected[@]}"; do
[ -f "artifacts/release-assets/$file" ] || missing+=("$file")
done
if [ "${#missing[@]}" -gt 0 ]; then
printf '::error::Missing complete release assets: %s\n' "${missing[*]}"
exit 1
fi
- name: Download update metadata artifacts
uses: actions/download-artifact@v4
with:
pattern: desktop-update-metadata-*
path: artifacts/update-metadata
merge-multiple: true
- name: Merge standard update metadata
run: bun run scripts/release-update-metadata.ts --metadata-dir artifacts/update-metadata --out-dir artifacts/update-metadata-standard
- name: Validate standard update metadata set
shell: bash
run: |
expected=(
"latest-mac.yml"
"latest-linux.yml"
"latest-linux-arm64.yml"
"latest.yml"
)
missing=()
for file in "${expected[@]}"; do
[ -f "artifacts/update-metadata-standard/$file" ] || missing+=("$file")
done
if [ "${#missing[@]}" -gt 0 ]; then
printf '::error::Missing standard update metadata: %s\n' "${missing[*]}"
exit 1
fi
- name: Publish complete GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.version.outputs.value }}
name: Claude Code Haha v${{ steps.version.outputs.value }}
body: ${{ steps.release_notes.outputs.body }}
draft: ${{ github.event_name == 'workflow_dispatch' && inputs.draft || false }}
prerelease: false
fail_on_unmatched_files: true
files: |
artifacts/release-assets/**/*.dmg
artifacts/release-assets/**/*.zip
artifacts/release-assets/**/*.exe
artifacts/release-assets/**/*.AppImage
artifacts/release-assets/**/*.deb
artifacts/release-assets/**/*.blockmap
artifacts/update-metadata-standard/*.yml
desktop/scripts/install-macos-unsigned.sh