From 06454f8c77f0a76ff50c54e79b96a69d21c45a00 Mon Sep 17 00:00:00 2001 From: SingLinkNetwork Date: Tue, 11 Aug 2026 01:48:11 +0800 Subject: [PATCH] ci: add notarized macOS release gate --- .../workflows/pull-request-validation.yaml | 2 + .../workflows/release-macos-notarized.yaml | 167 ++++++++++++++++++ tool/verify_ci_layout.rb | 1 + tool/verify_macos_release_signing_workflow.rb | 37 ++++ 4 files changed, 207 insertions(+) create mode 100644 .github/workflows/release-macos-notarized.yaml create mode 100644 tool/verify_macos_release_signing_workflow.rb diff --git a/.github/workflows/pull-request-validation.yaml b/.github/workflows/pull-request-validation.yaml index 76605f66..6c7519df 100644 --- a/.github/workflows/pull-request-validation.yaml +++ b/.github/workflows/pull-request-validation.yaml @@ -589,6 +589,8 @@ jobs: script: tool/verify_custom_test_urls.rb - id: core-callback-cleanup script: tool/verify_core_callback_cleanup.rb + - id: macos-release-signing + script: tool/verify_macos_release_signing_workflow.rb - id: ci-layout script: tool/verify_ci_layout.rb diff --git a/.github/workflows/release-macos-notarized.yaml b/.github/workflows/release-macos-notarized.yaml new file mode 100644 index 00000000..38e1d924 --- /dev/null +++ b/.github/workflows/release-macos-notarized.yaml @@ -0,0 +1,167 @@ +name: notarized macOS release + +on: + workflow_dispatch: + inputs: + tag: + description: Existing v* tag to build and sign + required: true + type: string + publish: + description: Publish the verified DMG to the selected GitHub Release + required: true + default: false + type: boolean + +permissions: + contents: read + +env: + FLUTTER_VERSION: '3.44.4' + +jobs: + notarize: + name: Build, notarize, and verify macOS DMG + runs-on: macos-15-intel + permissions: + contents: write + env: + APPLE_DEVELOPER_ID_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }} + APPLE_DEVELOPER_ID_P12_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }} + APPLE_DEVELOPER_ID_APPLICATION: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION }} + APPLE_NOTARY_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }} + APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + steps: + - name: Validate tag and release secrets + shell: bash + run: | + set -euo pipefail + case '${{ inputs.tag }}' in + v[0-9]*) ;; + *) echo '::error::tag must be an existing version tag beginning with v'; exit 1 ;; + esac + for name in \ + APPLE_DEVELOPER_ID_P12_BASE64 \ + APPLE_DEVELOPER_ID_P12_PASSWORD \ + APPLE_DEVELOPER_ID_APPLICATION \ + APPLE_NOTARY_KEY_P8_BASE64 \ + APPLE_NOTARY_KEY_ID \ + APPLE_NOTARY_ISSUER_ID; do + if [[ -z "${!name}" ]]; then + echo "::error title=Missing release secret::${name} must be configured as a GitHub Actions secret" + exit 1 + fi + done + + - name: Checkout selected release tag + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + submodules: recursive + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + - name: Setup Xcode + shell: bash + run: | + set -euo pipefail + sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode_26.2.app + xcodebuild -version + + - name: Build release app and initial DMG + shell: bash + run: | + set -euo pipefail + flutter pub get + dart setup.dart macos --env stable -v + test -d build/macos/Build/Products/Release/FlClash.app + + - name: Import Developer ID identity + shell: bash + run: | + set -euo pipefail + keychain_password=$(uuidgen) + echo "$APPLE_DEVELOPER_ID_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/developer-id.p12" + security create-keychain -p "$keychain_password" "$RUNNER_TEMP/flclash-release.keychain-db" + security set-keychain-settings -lut 21600 "$RUNNER_TEMP/flclash-release.keychain-db" + security unlock-keychain -p "$keychain_password" "$RUNNER_TEMP/flclash-release.keychain-db" + security import "$RUNNER_TEMP/developer-id.p12" -k "$RUNNER_TEMP/flclash-release.keychain-db" -P "$APPLE_DEVELOPER_ID_P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$RUNNER_TEMP/flclash-release.keychain-db" + security list-keychain -d user -s "$RUNNER_TEMP/flclash-release.keychain-db" login.keychain-db + + - name: Sign release app and rebuild DMG + id: package + shell: bash + run: | + set -euo pipefail + app_path="$GITHUB_WORKSPACE/build/macos/Build/Products/Release/FlClash.app" + version=$(awk '/^version:/ { split($2, parts, "+"); print parts[1]; exit }' pubspec.yaml) + dmg_path="$RUNNER_TEMP/FlClash-${version}-macos-amd64.dmg" + staging_dir=$(mktemp -d) + codesign --force --deep --options runtime --timestamp --sign "$APPLE_DEVELOPER_ID_APPLICATION" "$app_path" + codesign --verify --deep --strict --verbose=4 "$app_path" + cp -R "$app_path" "$staging_dir/FlClash.app" + ( + cd "$staging_dir" + appdmg "$GITHUB_WORKSPACE/macos/packaging/dmg/make_config.yaml" "$dmg_path" + ) + rm -rf "$staging_dir" + test -f "$dmg_path" + echo "dmg_path=$dmg_path" >> "$GITHUB_OUTPUT" + + - name: Notarize and staple final DMG + shell: bash + run: | + set -euo pipefail + echo "$APPLE_NOTARY_KEY_P8_BASE64" | base64 --decode > "$RUNNER_TEMP/AuthKey_${APPLE_NOTARY_KEY_ID}.p8" + xcrun notarytool submit '${{ steps.package.outputs.dmg_path }}' \ + --key "$RUNNER_TEMP/AuthKey_${APPLE_NOTARY_KEY_ID}.p8" \ + --key-id "$APPLE_NOTARY_KEY_ID" \ + --issuer "$APPLE_NOTARY_ISSUER_ID" \ + --wait + xcrun stapler staple '${{ steps.package.outputs.dmg_path }}' + xcrun stapler validate '${{ steps.package.outputs.dmg_path }}' + + - name: Verify mounted DMG with Gatekeeper + shell: bash + run: | + set -euo pipefail + mount_dir=$(mktemp -d) + trap 'hdiutil detach "$mount_dir" -quiet || true; rmdir "$mount_dir" || true' EXIT + hdiutil attach -nobrowse -readonly -mountpoint "$mount_dir" '${{ steps.package.outputs.dmg_path }}' + mounted_app="$mount_dir/FlClash.app" + test -d "$mounted_app" + codesign --verify --deep --strict --verbose=4 "$mounted_app" + spctl --assess --type execute --verbose=4 "$mounted_app" + xcrun stapler validate '${{ steps.package.outputs.dmg_path }}' + + - name: Upload verified notarized DMG + uses: actions/upload-artifact@v4 + with: + name: notarized-macos-dmg-${{ inputs.tag }} + path: ${{ steps.package.outputs.dmg_path }} + if-no-files-found: error + + - name: Publish verified DMG + if: ${{ inputs.publish == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + gh release view '${{ inputs.tag }}' --repo "$GITHUB_REPOSITORY" >/dev/null || \ + gh release create '${{ inputs.tag }}' --repo "$GITHUB_REPOSITORY" --title '${{ inputs.tag }}' --generate-notes + gh release upload '${{ inputs.tag }}' '${{ steps.package.outputs.dmg_path }}' --repo "$GITHUB_REPOSITORY" --clobber + + - name: Remove temporary signing material + if: ${{ always() }} + shell: bash + run: | + security delete-keychain "$RUNNER_TEMP/flclash-release.keychain-db" || true + rm -f "$RUNNER_TEMP/developer-id.p12" "$RUNNER_TEMP/AuthKey_${APPLE_NOTARY_KEY_ID}.p8" diff --git a/tool/verify_ci_layout.rb b/tool/verify_ci_layout.rb index 5e3e5222..c45cb567 100644 --- a/tool/verify_ci_layout.rb +++ b/tool/verify_ci_layout.rb @@ -86,6 +86,7 @@ tool/verify_subscription_sync_route.rb tool/verify_custom_test_urls.rb tool/verify_core_callback_cleanup.rb + tool/verify_macos_release_signing_workflow.rb tool/verify_ci_layout.rb ] actual_scripts = static_entries.map { |entry| entry.fetch('script') } diff --git a/tool/verify_macos_release_signing_workflow.rb b/tool/verify_macos_release_signing_workflow.rb new file mode 100644 index 00000000..c210ee95 --- /dev/null +++ b/tool/verify_macos_release_signing_workflow.rb @@ -0,0 +1,37 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +workflow_path = File.expand_path('../.github/workflows/release-macos-notarized.yaml', __dir__) +abort "Missing release signing workflow: #{workflow_path}" unless File.file?(workflow_path) + +workflow = File.read(workflow_path) +required_markers = %w[ + workflow_dispatch + APPLE_DEVELOPER_ID_P12_BASE64 + APPLE_DEVELOPER_ID_P12_PASSWORD + APPLE_DEVELOPER_ID_APPLICATION + APPLE_NOTARY_KEY_P8_BASE64 + APPLE_NOTARY_KEY_ID + APPLE_NOTARY_ISSUER_ID + codesign\ --verify\ --deep\ --strict\ --verbose=4 + xcrun\ notarytool\ submit + xcrun\ stapler\ staple + xcrun\ stapler\ validate + spctl\ --assess\ --type\ execute\ --verbose=4 +] + +missing_markers = required_markers.reject { |marker| workflow.include?(marker) } +abort "Release signing workflow is missing: #{missing_markers.join(', ')}" unless missing_markers.empty? + +abort 'Release signing workflow must use workflow_dispatch as its only trigger' unless + workflow.match?(/^on:\s*\n\s+workflow_dispatch:/) + +forbidden_triggers = %w[push pull_request pull_request_target schedule workflow_call repository_dispatch] +unexpected_triggers = forbidden_triggers.select { |trigger| workflow.match?(/^\s+#{Regexp.escape(trigger)}:/) } +abort "Release signing workflow must remain manual-only: #{unexpected_triggers.join(', ')}" unless unexpected_triggers.empty? + +abort 'Release publication must be guarded by publish=true' unless workflow.include?("inputs.publish == 'true'") +abort 'Release publication must occur after Gatekeeper verification' unless + workflow.index('Publish verified DMG') > workflow.rindex('spctl --assess --type execute --verbose=4') + +puts 'macOS notarized release workflow verification passed'