name: Release Desktop on: push: tags: ['v*.*.*'] workflow_dispatch: inputs: draft: description: 'Create as draft release' 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 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 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 }} run: node ./node_modules/electron-builder/out/cli/cli.js ${{ matrix.builder_args }} --publish never - 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' run: bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/electron --require-macos-gatekeeper - 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