name: 'Flutter Build macOS' description: 'Builds, signs (Developer ID & Sparkle), packages (.dmg / .zip), notarizes, and generates release metadata for macOS Flutter desktop apps.' inputs: working_directory: description: 'Path to the Flutter app package directory (e.g., packages/sfw_desktop)' required: false default: '.' app_name: description: 'Display name of the .app bundle (e.g., "Slides for Worship" or auto-detected if empty)' required: false default: '' build_args: description: 'Extra arguments passed to flutter build macos (e.g., --dart-define=...)' required: false default: '' disable_spm: description: 'Disable Flutter Swift Package Manager before building' required: false default: 'true' run_pod_install: description: 'Run pod install inside working_directory/macos before building' required: false default: 'false' package_type: description: 'Packaging format to produce: dmg, zip, or both' required: false default: 'dmg' dmg_filename: description: 'Output DMG filename (e.g., slides-for-worship-macos-universal.dmg)' required: false default: '' zip_filename: description: 'Output ZIP path/filename when package_type is zip or both' required: false default: '' entitlements_path: description: 'Path to entitlements plist relative to working_directory' required: false default: 'macos/Runner/Release.entitlements' signing_identity: description: 'Fallback Developer ID Application signing identity if not auto-detected' required: false default: '' certificate_base64: description: 'Base64-encoded Apple .p12 certificate' required: false default: '' certificate_password: description: 'Password for the Apple .p12 certificate' required: false default: '' certificate_file: description: 'Fallback path to local .p12 certificate file in repository' required: false default: 'signing/macos/certificate.p12' metadata_txt_file: description: 'Fallback path to metadata.txt containing p12/notarization credentials' required: false default: 'signing/macos/metadata.txt' provisioning_profile_base64: description: 'Optional Base64-encoded .provisionprofile for Mac App Store / provisioning' required: false default: '' notarize_apple_id: description: 'Apple ID email for DMG notarization' required: false default: '' notarize_password: description: 'Apple ID App-Specific Password for DMG notarization' required: false default: '' notarize_team_id: description: 'Apple Developer 10-character Team ID for DMG notarization' required: false default: '' sparkle_private_key: description: 'Sparkle EdDSA private key content for signing DMG updates' required: false default: '' sparkle_key_file: description: 'Fallback path to Sparkle EdDSA private key file' required: false default: 'signing/sparkle/sparkle_key.pem' generate_metadata_json: description: 'Whether to generate macos_metadata.json and macos_signature.txt' required: false default: 'false' asc_key_id: description: 'Optional App Store Connect API Key ID for .pkg upload' required: false default: '' asc_issuer_id: description: 'Optional App Store Connect API Issuer ID for .pkg upload' required: false default: '' asc_private_key: description: 'Optional App Store Connect API Private Key (.p8) for .pkg upload' required: false default: '' outputs: version: description: 'Resolved package version from pubspec.yaml' value: ${{ steps.build.outputs.version }} dmg_path: description: 'Path to generated DMG installer' value: ${{ steps.package.outputs.dmg_path }} zip_path: description: 'Path to generated ZIP archive' value: ${{ steps.package.outputs.zip_path }} signature: description: 'Sparkle EdDSA signature if generated' value: ${{ steps.sparkle.outputs.signature }} metadata_path: description: 'Path to generated macos_metadata.json' value: ${{ steps.metadata.outputs.metadata_path }} runs: using: 'composite' steps: - name: Clean up previous temporary keychains shell: bash run: | security list-keychains -d user -s "$HOME/Library/Keychains/login.keychain-db" || true security default-keychain -s "$HOME/Library/Keychains/login.keychain-db" || true security delete-keychain "$HOME/Library/Keychains/signing_macos_temp.keychain-db" 2>/dev/null || true rm -f "$HOME/Library/Keychains/signing_macos_temp.keychain-db" || true - name: Import Code-Signing Certificates shell: bash env: CERT_B64: ${{ inputs.certificate_base64 }} CERT_PWD: ${{ inputs.certificate_password }} CERT_FILE: ${{ inputs.certificate_file }} METADATA_FILE: ${{ inputs.metadata_txt_file }} PROFILE_B64: ${{ inputs.provisioning_profile_base64 }} run: | CERT_P12="$CERT_FILE" if [ -z "$CERT_PWD" ] && [ -n "$METADATA_FILE" ] && [ -f "$METADATA_FILE" ]; then CERT_PWD=$(grep -A 1 "p12 password:" "$METADATA_FILE" | tail -n 1 | tr -d '\r\n' || true) fi if [ -n "$CERT_B64" ]; then echo "$CERT_B64" | base64 --decode > /tmp/macos_certificate.p12 CERT_P12="/tmp/macos_certificate.p12" fi if [ -f "$CERT_P12" ] && [ -n "$CERT_PWD" ]; then echo "Importing code-signing certificate from $CERT_P12..." KEYCHAIN="$HOME/Library/Keychains/signing_macos_temp.keychain-db" KEYCHAIN_PWD="temp_password_123" security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" || true security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" || true security set-keychain-settings -lut 21600 "$KEYCHAIN" || true security list-keychains -d user -s "$HOME/Library/Keychains/login.keychain-db" "$KEYCHAIN" || true security default-keychain -s "$HOME/Library/Keychains/login.keychain-db" || true security import "$CERT_P12" -k "$KEYCHAIN" -P "$CERT_PWD" -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild -A || true security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PWD" "$KEYCHAIN" || true echo "Certificate imported successfully. Available identities:" security find-identity -v -p codesigning || true else echo "Notice: No certificate or password found; building unsigned or ad-hoc signed macOS bundle." fi if [ -n "$PROFILE_B64" ]; then mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" echo "$PROFILE_B64" | base64 --decode > /tmp/macos_profile.provisionprofile PROFILE_UUID=$(security cms -D -i /tmp/macos_profile.provisionprofile 2>/dev/null | plutil -extract UUID raw - 2>/dev/null || echo "macos_profile") cp /tmp/macos_profile.provisionprofile "$HOME/Library/MobileDevice/Provisioning Profiles/${PROFILE_UUID}.provisionprofile" cp /tmp/macos_profile.provisionprofile "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/${PROFILE_UUID}.provisionprofile" fi - name: Install CocoaPods & Build macOS Release id: build shell: bash env: WORK_DIR: ${{ inputs.working_directory }} RUN_PODS: ${{ inputs.run_pod_install }} DISABLE_SPM: ${{ inputs.disable_spm }} BUILD_ARGS: ${{ inputs.build_args }} run: | export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:$PATH" ROOT_DIR="$(pwd)" VERSION=$(grep '^version: ' "$WORK_DIR/pubspec.yaml" | head -n 1 | cut -d ' ' -f 2 | tr -d '\r') echo "version=$VERSION" >> "$GITHUB_OUTPUT" if [ "$RUN_PODS" = "true" ] && [ -d "$WORK_DIR/macos" ]; then cd "$WORK_DIR/macos" pod install || true cd "$ROOT_DIR" fi cd "$WORK_DIR" flutter config --enable-macos-desktop if [ "$DISABLE_SPM" = "true" ]; then flutter config --no-enable-swift-package-manager fi # shellcheck disable=SC2086 flutter build macos --release $BUILD_ARGS cd "$ROOT_DIR" - name: Sign App Bundle (Developer ID & Sparkle) shell: bash env: WORK_DIR: ${{ inputs.working_directory }} APP_NAME: ${{ inputs.app_name }} ENTITLEMENTS_REL: ${{ inputs.entitlements_path }} FALLBACK_IDENTITY: ${{ inputs.signing_identity }} run: | if [ -n "$APP_NAME" ]; then APP_PATH="$WORK_DIR/build/macos/Build/Products/Release/${APP_NAME}.app" else APP_PATH=$(find "$WORK_DIR/build/macos/Build/Products/Release" -maxdepth 1 -name "*.app" | head -n 1) fi ENTITLEMENTS="$WORK_DIR/$ENTITLEMENTS_REL" IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null | grep "Developer ID Application" | head -1 | grep -oE '"[^"]+"' | sed 's/"//g' || true) if [ -z "$IDENTITY" ] && [ -n "$FALLBACK_IDENTITY" ]; then IDENTITY="$FALLBACK_IDENTITY" fi if [ -n "$APP_PATH" ] && [ -d "$APP_PATH" ] && [ -n "$IDENTITY" ]; then echo "Signing Sparkle components, frameworks, and App Bundle at $APP_PATH with identity: $IDENTITY" SPARKLE_FW="$APP_PATH/Contents/Frameworks/Sparkle.framework/Versions/B" if [ -d "$SPARKLE_FW" ]; then echo "Signing Sparkle internal components..." [ -d "$SPARKLE_FW/XPCServices/Downloader.xpc" ] && codesign --force --options runtime --timestamp --sign "$IDENTITY" "$SPARKLE_FW/XPCServices/Downloader.xpc" || true [ -d "$SPARKLE_FW/XPCServices/Installer.xpc" ] && codesign --force --options runtime --timestamp --sign "$IDENTITY" "$SPARKLE_FW/XPCServices/Installer.xpc" || true [ -f "$SPARKLE_FW/Autoupdate" ] && codesign --force --options runtime --timestamp --sign "$IDENTITY" "$SPARKLE_FW/Autoupdate" || true [ -d "$SPARKLE_FW/Updater.app" ] && codesign --force --options runtime --timestamp --sign "$IDENTITY" "$SPARKLE_FW/Updater.app" || true codesign --force --options runtime --timestamp --sign "$IDENTITY" "$APP_PATH/Contents/Frameworks/Sparkle.framework" || true fi if [ -d "$APP_PATH/Contents/Frameworks" ]; then find "$APP_PATH/Contents/Frameworks" -mindepth 1 -maxdepth 1 2>/dev/null | while read -r FW; do if [ "$FW" != "$APP_PATH/Contents/Frameworks/Sparkle.framework" ]; then codesign --force --options runtime --timestamp --sign "$IDENTITY" "$FW" || true fi done fi if [ -f "$ENTITLEMENTS" ]; then codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" --sign "$IDENTITY" "$APP_PATH" || true else codesign --force --options runtime --timestamp --sign "$IDENTITY" "$APP_PATH" || true fi codesign -dv --verbose=4 "$APP_PATH" || true fi - name: Package macOS Installer (DMG / ZIP) id: package shell: bash env: WORK_DIR: ${{ inputs.working_directory }} APP_NAME: ${{ inputs.app_name }} PACKAGE_TYPE: ${{ inputs.package_type }} DMG_FILENAME: ${{ inputs.dmg_filename }} ZIP_FILENAME: ${{ inputs.zip_filename }} run: | if [ -n "$APP_NAME" ]; then APP_PATH="$WORK_DIR/build/macos/Build/Products/Release/${APP_NAME}.app" else APP_PATH=$(find "$WORK_DIR/build/macos/Build/Products/Release" -maxdepth 1 -name "*.app" | head -n 1) fi if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then echo "::error::macOS .app bundle not found in $WORK_DIR/build/macos/Build/Products/Release" exit 1 fi BUNDLE_BASENAME=$(basename "$APP_PATH") VOL_NAME="${APP_NAME:-${BUNDLE_BASENAME%.app}}" if [ "$PACKAGE_TYPE" = "zip" ] || [ "$PACKAGE_TYPE" = "both" ]; then ZIP_OUT="${ZIP_FILENAME:-$WORK_DIR/build/macos/release-archive/${VOL_NAME// /-}-macOS.zip}" mkdir -p "$(dirname "$ZIP_OUT")" ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_OUT" echo "Packaged macOS ZIP archive: $ZIP_OUT" echo "zip_path=$ZIP_OUT" >> "$GITHUB_OUTPUT" fi if [ "$PACKAGE_TYPE" = "dmg" ] || [ "$PACKAGE_TYPE" = "both" ]; then DMG_OUT="${DMG_FILENAME:-${VOL_NAME// /-}-macos-universal.dmg}" if ! command -v create-dmg >/dev/null 2>&1; then brew install create-dmg || true fi echo "Detaching any leftover disk image mounts..." for dev in $(hdiutil info 2>/dev/null | awk -v vol="$VOL_NAME" '$0 ~ "/Volumes/.*" vol "|/Volumes/.*Presentation-Creator" {print $1}'); do [ -n "$dev" ] && hdiutil detach "$dev" -force || true done rm -f "$DMG_OUT" DMG_STAGING="dmg_staging" rm -rf "$DMG_STAGING" mkdir -p "$DMG_STAGING" cp -R "$APP_PATH" "$DMG_STAGING/" create-dmg \ --volname "$VOL_NAME" \ --window-pos 200 120 \ --window-size 800 400 \ --icon-size 100 \ --icon "$BUNDLE_BASENAME" 200 190 \ --hide-extension "$BUNDLE_BASENAME" \ --app-drop-link 600 185 \ --no-internet-enable \ --sandbox-safe \ --skip-jenkins \ --overwrite \ "$DMG_OUT" \ "$DMG_STAGING" || true if [ ! -f "$DMG_OUT" ]; then echo "create-dmg did not generate DMG, running hdiutil fallback with Applications link..." ln -s /Applications "$DMG_STAGING/Applications" 2>/dev/null || true hdiutil create -volname "$VOL_NAME" -srcfolder "$DMG_STAGING" -ov -format UDZO "$DMG_OUT" fi rm -rf "$DMG_STAGING" for dev in $(hdiutil info 2>/dev/null | awk -v vol="$VOL_NAME" '$0 ~ "/Volumes/.*" vol "|/Volumes/.*Presentation-Creator" {print $1}'); do [ -n "$dev" ] && hdiutil detach "$dev" -force || true done test -f "$DMG_OUT" echo "dmg_path=$DMG_OUT" >> "$GITHUB_OUTPUT" IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null | grep "Developer ID Application" | head -1 | grep -oE '"[^"]+"' | sed 's/"//g' || true) if [ -n "$IDENTITY" ] && [ -f "$DMG_OUT" ]; then echo "Signing DMG with identity: $IDENTITY" codesign --force --timestamp --sign "$IDENTITY" "$DMG_OUT" || true fi fi - name: Notarize & Staple DMG if: inputs.package_type == 'dmg' || inputs.package_type == 'both' shell: bash env: DMG_FILE: ${{ steps.package.outputs.dmg_path }} AC_USER: ${{ inputs.notarize_apple_id }} AC_PASS: ${{ inputs.notarize_password }} AC_TEAM: ${{ inputs.notarize_team_id }} METADATA_FILE: ${{ inputs.metadata_txt_file }} run: | if [ -z "$AC_USER" ] && [ -n "$METADATA_FILE" ] && [ -f "$METADATA_FILE" ]; then AC_USER=$(grep -A 1 "AC username:" "$METADATA_FILE" | tail -n 1 | tr -d '\r\n' || true) AC_PASS=$(grep -A 1 "AC password:" "$METADATA_FILE" | tail -n 1 | tr -d '\r\n' || true) AC_TEAM=$(grep -A 1 "Team ID:" "$METADATA_FILE" | tail -n 1 | tr -d '\r\n' || true) fi if [ -n "$AC_USER" ] && [ -n "$AC_PASS" ] && [ -n "$AC_TEAM" ] && [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then echo "Submitting $DMG_FILE to Apple Notary Service..." xcrun notarytool submit "$DMG_FILE" \ --apple-id "$AC_USER" \ --password "$AC_PASS" \ --team-id "$AC_TEAM" \ --wait || true echo "Stapling notarization ticket..." for i in {1..5}; do xcrun stapler staple "$DMG_FILE" && break || sleep 10 done || true else echo "Notarization credentials not configured; skipping notarization." fi - name: Sign DMG for Sparkle id: sparkle if: inputs.package_type == 'dmg' || inputs.package_type == 'both' shell: bash env: WORK_DIR: ${{ inputs.working_directory }} DMG_FILE: ${{ steps.package.outputs.dmg_path }} SPARKLE_KEY: ${{ inputs.sparkle_private_key }} SPARKLE_KEY_FILE: ${{ inputs.sparkle_key_file }} run: | ROOT_DIR="$(pwd)" touch macos_signature.txt if [ -z "$SPARKLE_KEY" ] && [ -n "$SPARKLE_KEY_FILE" ] && [ -f "$SPARKLE_KEY_FILE" ]; then SPARKLE_KEY=$(cat "$SPARKLE_KEY_FILE") fi if [ -n "$SPARKLE_KEY" ] && [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then echo "$SPARKLE_KEY" > "$ROOT_DIR/sparkle_key.pem" echo "Signing $DMG_FILE for Sparkle..." cd "$WORK_DIR" OUTPUT=$(dart run auto_updater:sign_update "$ROOT_DIR/$DMG_FILE" --ed-key-file "$ROOT_DIR/sparkle_key.pem" 2>&1 || true) echo "$OUTPUT" cd "$ROOT_DIR" SIG=$(echo "$OUTPUT" | grep -o 'Signature="[^"]*"' | head -n 1 | cut -d'"' -f2 || true) if [ -n "$SIG" ]; then echo "$SIG" > macos_signature.txt echo "signature=$SIG" >> "$GITHUB_OUTPUT" fi rm -f "$ROOT_DIR/sparkle_key.pem" fi - name: Generate macOS Release Metadata id: metadata if: inputs.generate_metadata_json == 'true' shell: bash env: VERSION: ${{ steps.build.outputs.version }} DMG_FILE: ${{ steps.package.outputs.dmg_path }} run: | SIG=$(cat macos_signature.txt 2>/dev/null || true) SIZE=$(stat -c%s "$DMG_FILE" 2>/dev/null || stat -f%z "$DMG_FILE" 2>/dev/null || echo "0") FILENAME=$(basename "$DMG_FILE") cat < macos_metadata.json { "version": "$VERSION", "platform": "macos", "arch": "universal", "filename": "$FILENAME", "size": $SIZE, "signature": "$SIG", "signatureType": "edSignature" } EOF cat macos_metadata.json echo "metadata_path=macos_metadata.json" >> "$GITHUB_OUTPUT" - name: Upload macOS Package to App Store Connect (Optional) if: inputs.asc_key_id != '' && inputs.asc_issuer_id != '' && inputs.asc_private_key != '' shell: bash env: WORK_DIR: ${{ inputs.working_directory }} ASC_KEY_ID: ${{ inputs.asc_key_id }} ASC_ISSUER_ID: ${{ inputs.asc_issuer_id }} ASC_KEY_CONTENT: ${{ inputs.asc_private_key }} run: | PKG_FILE=$(ls "$WORK_DIR"/build/macos/*.pkg 2>/dev/null | head -n 1 || true) if [ -z "$PKG_FILE" ]; then echo "Skipping Mac App Store pkg upload (no signed .pkg generated)." exit 0 fi mkdir -p "$HOME/.appstoreconnect/private_keys" echo "$ASC_KEY_CONTENT" > "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" xcrun altool --upload-app \ -f "$PKG_FILE" \ --type osx \ --apiKey "$ASC_KEY_ID" \ --apiIssuer "$ASC_ISSUER_ID" - name: Eject DMG Mounts & Clean Up Keychain if: always() shell: bash env: APP_NAME: ${{ inputs.app_name }} run: | VOL_NAME="${APP_NAME:-Slides for Worship}" for dev in $(hdiutil info 2>/dev/null | awk -v vol="$VOL_NAME" '$0 ~ "/Volumes/.*" vol "|/Volumes/.*Presentation-Creator" {print $1}'); do [ -n "$dev" ] && hdiutil detach "$dev" -force || true done security list-keychains -d user -s "$HOME/Library/Keychains/login.keychain-db" || true security default-keychain -s "$HOME/Library/Keychains/login.keychain-db" || true security delete-keychain "$HOME/Library/Keychains/signing_macos_temp.keychain-db" 2>/dev/null || true rm -f "$HOME/Library/Keychains/signing_macos_temp.keychain-db" /tmp/macos_certificate.p12 /tmp/macos_profile.provisionprofile || true