diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 8150432a..bd9e704f 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -76,11 +76,13 @@ jobs: - 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 @@ -191,9 +193,9 @@ jobs: 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 ) + 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 @@ -234,25 +236,118 @@ jobs: 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 + 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 - 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 + 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' diff --git a/scripts/pr/release-workflow.test.ts b/scripts/pr/release-workflow.test.ts index c89d23ec..d8089e40 100644 --- a/scripts/pr/release-workflow.test.ts +++ b/scripts/pr/release-workflow.test.ts @@ -129,6 +129,8 @@ describe('release desktop workflow', () => { const signedBuildStep = extractStep(workflow, 'Build signed macOS Electron release artifacts') const unsignedBuildStep = extractStep(workflow, 'Build unsigned Electron release artifacts') + expect(workflow).toContain('app_bundle_dir: mac-arm64') + expect(workflow).toContain('app_bundle_dir: mac') expect(signedBuildStep).toContain("if: matrix.smoke_platform == 'macos' && needs.signing-preflight.outputs.macos_signed == 'true'") expect(signedBuildStep).toContain('CSC_LINK: ${{ secrets.MACOS_CERTIFICATE }}') expect(signedBuildStep).toContain('CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}') @@ -143,16 +145,26 @@ describe('release desktop workflow', () => { expect(signedBuildStep).toContain('xcrun --find notarytool') expect(signedBuildStep).toContain('security find-identity -v -p codesigning') expect(signedBuildStep).toContain('macOS notarization requested: ${MACOS_NOTARIZE}') - expect(signedBuildStep).toContain('max_attempts=2') - expect(signedBuildStep).toContain('build_timeout_seconds=2100') + expect(signedBuildStep).toContain('builder_args=( ${{ matrix.builder_args }} --publish never -c.mac.notarize=false )') expect(signedBuildStep).toContain('max_attempts=1') expect(signedBuildStep).toContain('build_timeout_seconds=900') expect(signedBuildStep).toContain('-c.mac.notarize=false') expect(signedBuildStep).toContain('macOS notarization is disabled for this draft run') expect(signedBuildStep).toContain('run_signed_electron_builder') + expect(signedBuildStep).toContain('run_electron_builder_with_retries') + expect(signedBuildStep).toContain('notarize_app_bundle') + expect(signedBuildStep).toContain('xcrun notarytool submit "$notary_zip"') + expect(signedBuildStep).toContain('--timeout "$notary_timeout"') + expect(signedBuildStep).toContain('notary_attempts=3') + expect(signedBuildStep).toContain('xcrun stapler staple "$app_path"') + expect(signedBuildStep).toContain('xcrun stapler validate "$app_path"') + expect(signedBuildStep).toContain('spctl -a -vv -t execute "$app_path"') + expect(signedBuildStep).toContain('app_path="build-artifacts/electron/${{ matrix.app_bundle_dir }}/Claude Code Haha.app"') + expect(signedBuildStep).toContain('package_args=( ${{ matrix.builder_args }} --prepackaged "$app_path" --publish never -c.mac.notarize=false )') + expect(signedBuildStep).toContain('find build-artifacts/electron -maxdepth 1 -type f -delete') expect(signedBuildStep).toContain('Signed electron-builder timed out') expect(signedBuildStep).toContain('pkill -TERM -P "$build_pid"') - expect(signedBuildStep).toContain('with ${build_timeout_seconds}s watchdog') + expect(signedBuildStep).toContain('with ${timeout_seconds}s watchdog') expect(signedBuildStep).toContain('set +e') expect(signedBuildStep).toContain('status=$?') expect(signedBuildStep).toContain('if [ "$status" -eq 0 ]; then')