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 app_bundle_dir: mac-arm64 label: macOS-ARM64 smoke_platform: macos - platform: macos-latest target_triple: x86_64-apple-darwin builder_args: --mac dmg zip --x64 app_bundle_dir: mac 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=1 build_timeout_seconds=900 builder_args=( ${{ matrix.builder_args }} --publish never -c.mac.notarize=false ) 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" } run_electron_builder_with_retries() { local attempts="$1" local timeout_seconds="$2" local clean_before_attempt="$3" shift 3 local status=0 for attempt in $(seq 1 "$attempts"); do echo "Starting signed electron-builder attempt ${attempt}/${attempts} at $(date -u '+%Y-%m-%dT%H:%M:%SZ') with ${timeout_seconds}s watchdog" if [ "$clean_before_attempt" = "true" ]; then rm -rf build-artifacts/electron fi set +e run_signed_electron_builder "$timeout_seconds" "$@" status=$? set -e if [ "$status" -eq 0 ]; then echo "Finished signed electron-builder attempt ${attempt}/${attempts} at $(date -u '+%Y-%m-%dT%H:%M:%SZ')" return 0 fi if [ "$attempt" -eq "$attempts" ]; then echo "::error::Signed electron-builder failed after ${attempts} attempts" return "$status" fi echo "::warning::Signed electron-builder attempt ${attempt}/${attempts} failed with exit code ${status}; retrying after 120 seconds" sleep 120 done } notarize_app_bundle() { local app_path="$1" local notary_zip="${RUNNER_TEMP}/${{ matrix.label }}-notary.zip" local notary_attempts=3 local notary_timeout=20m local status=0 if [ ! -d "$app_path" ]; then echo "::error::Expected signed macOS app bundle does not exist: ${app_path}" return 1 fi echo "::group::macOS notarization" echo "Verifying signed app before notarization: ${app_path}" codesign --verify --deep --strict --verbose=2 "$app_path" rm -f "$notary_zip" ( cd "$(dirname "$app_path")" ditto -c -k --sequesterRsrc --keepParent "$(basename "$app_path")" "$notary_zip" ) for attempt in $(seq 1 "$notary_attempts"); do echo "Starting notarytool attempt ${attempt}/${notary_attempts} at $(date -u '+%Y-%m-%dT%H:%M:%SZ') with ${notary_timeout} timeout" set +e xcrun notarytool submit "$notary_zip" \ --apple-id "$APPLE_ID" \ --password "$APPLE_APP_SPECIFIC_PASSWORD" \ --team-id "$APPLE_TEAM_ID" \ --wait \ --timeout "$notary_timeout" \ --output-format json status=$? set -e if [ "$status" -eq 0 ]; then echo "Notarytool attempt ${attempt}/${notary_attempts} succeeded at $(date -u '+%Y-%m-%dT%H:%M:%SZ')" xcrun stapler staple "$app_path" xcrun stapler validate "$app_path" spctl -a -vv -t execute "$app_path" echo "::endgroup::" return 0 fi if [ "$attempt" -eq "$notary_attempts" ]; then echo "::endgroup::" echo "::error::notarytool failed after ${notary_attempts} attempts" return "$status" fi echo "::warning::notarytool attempt ${attempt}/${notary_attempts} failed with exit code ${status}; retrying after 120 seconds" sleep 120 done } set +e run_electron_builder_with_retries "$max_attempts" "$build_timeout_seconds" true node ./node_modules/electron-builder/out/cli/cli.js "${builder_args[@]}" status=$? set -e if [ "$status" -ne 0 ]; then exit "$status" fi if [ "$MACOS_NOTARIZE" != "true" ]; then exit 0 fi app_path="build-artifacts/electron/${{ matrix.app_bundle_dir }}/Claude Code Haha.app" set +e notarize_app_bundle "$app_path" status=$? set -e if [ "$status" -ne 0 ]; then exit "$status" fi package_args=( ${{ matrix.builder_args }} --prepackaged "$app_path" --publish never -c.mac.notarize=false ) find build-artifacts/electron -maxdepth 1 -type f -delete set +e run_electron_builder_with_retries 1 900 false node ./node_modules/electron-builder/out/cli/cli.js "${package_args[@]}" status=$? set -e if [ "$status" -ne 0 ]; then exit "$status" fi - 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 == true }} 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 - name: Ensure workflow-dispatch release remains draft if: github.event_name == 'workflow_dispatch' && inputs.draft == true env: GH_TOKEN: ${{ github.token }} run: gh release edit "v${{ steps.version.outputs.value }}" --draft --repo "${{ github.repository }}"