mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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
507 lines
20 KiB
YAML
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
|