Files
shared-actions/flutter-build-macos/action.yml
T
2026-10-18 13:55:08 -07:00

464 lines
20 KiB
YAML

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 <<EOF > 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