diff --git a/README.md b/README.md index d941ab9..c43478a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,11 @@ This repository contains reusable composite actions for CI/CD workflows across o | :--- | :--- | :--- | | [Deploy to Coolify](./coolify-deploy) | Triggers a Coolify deployment using a webhook and authorization token. | `rodydavis/shared-actions/coolify-deploy@main` | | [Enforce Flutter Version Constraint](./enforce-flutter-version) | Scans all pubspec.yaml files to ensure they use the required Flutter SDK constraint. | `rodydavis/shared-actions/enforce-flutter-version@main` | +| [Flutter Build Android](./flutter-build-android) | Configures Android keystore signing, builds release AAB/APK artifacts, and optionally deploys to Google Play Console and Shorebird. | `rodydavis/shared-actions/flutter-build-android@main` | +| [Flutter Build iOS](./flutter-build-ios) | Installs Apple signing certificates and provisioning profiles in an isolated keychain, builds an iOS release IPA, and optionally uploads to TestFlight and Shorebird. | `rodydavis/shared-actions/flutter-build-ios@main` | +| [Flutter Build Linux](./flutter-build-linux) | Installs GTK3/Linux build dependencies, builds Linux Flutter desktop apps, packages .tar.gz and .deb installers, and generates release metadata. | `rodydavis/shared-actions/flutter-build-linux@main` | +| [Flutter Build macOS](./flutter-build-macos) | Builds, signs (Developer ID & Sparkle), packages (.dmg / .zip), notarizes, and generates release metadata for macOS Flutter desktop apps. | `rodydavis/shared-actions/flutter-build-macos@main` | +| [Flutter Build Windows](./flutter-build-windows) | Builds Windows Flutter desktop apps, compiles Inno Setup (.exe) and MSIX (.msix) installers, signs updates for WinSparkle (DSA), and generates release metadata. | `rodydavis/shared-actions/flutter-build-windows@main` | | [Deploy Static Pages](./pages-deploy) | Packages static web assets into a hardened Nginx container, pushes to Gitea registry, and deploys to TrueNAS with Traefik ingress. | `rodydavis/shared-actions/pages-deploy@main` | | [Setup Flutter](./setup-flutter) | Cross-platform setup for Flutter with local mirror and multi-runner concurrency support. | `rodydavis/shared-actions/setup-flutter@main` | | [Deploy to TrueNAS](./truenas-deploy) | Deploys a Docker Compose stack to TrueNAS SCALE over SSH and pulls latest container images. | `rodydavis/shared-actions/truenas-deploy@main` | diff --git a/flutter-build-android/README.md b/flutter-build-android/README.md new file mode 100644 index 0000000..362a4f1 --- /dev/null +++ b/flutter-build-android/README.md @@ -0,0 +1,52 @@ +# Flutter Build Android + +Composite action to configure Android keystore signing, build release AAB and APK artifacts, and optionally upload to Google Play Console and Shorebird. + +## Inputs + +| Name | Description | Required | Default | +| :--- | :--- | :--- | :--- | +| `working_directory` | Path to the Flutter app package directory. | No | `.` | +| `build_aab` | Whether to build an Android App Bundle (`.aab`). | No | `true` | +| `build_apk` | Whether to build a release APK (`.apk`). | No | `false` | +| `build_args` | Extra arguments passed to `flutter build`. | No | `''` | +| `artifact_prefix` | Optional prefix for copied versioned artifacts in `output_dir`. | No | `''` | +| `output_dir` | Directory to copy versioned artifacts into when `artifact_prefix` is set. | No | `artifacts` | +| `keystore_base64` | Base64-encoded Android release keystore file. | No | `''` | +| `key_alias` | Android signing key alias. | No | `''` | +| `key_password` | Android signing key password. | No | `''` | +| `store_password` | Android keystore password. | No | `''` | +| `google_play_service_account_json` | Google Play service account JSON content. | No | `''` | +| `google_play_service_account_file` | Fallback path to Google Play service account JSON file. | No | `''` | +| `google_play_track` | Google Play track (`internal`, `alpha`, `beta`, `production`). | No | `beta` | +| `google_play_status` | Google Play release status (`completed`, `draft`, `inProgress`, `halted`). | No | `completed` | +| `google_play_script` | Path to the Dart Google Play upload script. | No | `scripts/upload_play_store.dart` | +| `shorebird_token` | Shorebird token for optional Shorebird release. | No | `''` | +| `shorebird_artifact` | Shorebird artifact type (`apk` or `aab`). | No | `apk` | + +## Outputs + +| Name | Description | +| :--- | :--- | +| `version` | Resolved package version from `pubspec.yaml`. | +| `aab_path` | Path to the built `.aab` file. | +| `apk_path` | Path to the built `.apk` file. | + +## Example Usage + +```yaml +- name: Build & Release Android + uses: rodydavis/shared-actions/flutter-build-android@main + with: + working_directory: packages/hfw_flutter + build_aab: 'true' + build_apk: 'true' + artifact_prefix: hfw-android + keystore_base64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + key_alias: ${{ secrets.ANDROID_KEY_ALIAS }} + key_password: ${{ secrets.ANDROID_KEY_PASSWORD }} + store_password: ${{ secrets.ANDROID_STORE_PASSWORD }} + google_play_service_account_json: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + google_play_track: beta + shorebird_token: ${{ secrets.SHOREBIRD_TOKEN }} +``` diff --git a/flutter-build-android/action.yml b/flutter-build-android/action.yml new file mode 100644 index 0000000..300684a --- /dev/null +++ b/flutter-build-android/action.yml @@ -0,0 +1,212 @@ +name: 'Flutter Build Android' +description: 'Configures Android keystore signing, builds release AAB/APK artifacts, and optionally deploys to Google Play Console and Shorebird.' +inputs: + working_directory: + description: 'Path to the Flutter app package directory (e.g., packages/hfw_flutter or .)' + required: false + default: '.' + build_aab: + description: 'Whether to build an Android App Bundle (.aab)' + required: false + default: 'true' + build_apk: + description: 'Whether to build a release APK (.apk)' + required: false + default: 'false' + build_args: + description: 'Extra arguments passed to flutter build appbundle / apk (e.g., --dart-define=...)' + required: false + default: '' + artifact_prefix: + description: 'Optional prefix for copied versioned artifacts in output_dir (e.g., hfw-android -> hfw-android-v1.0.0.apk)' + required: false + default: '' + output_dir: + description: 'Directory to copy versioned artifacts into when artifact_prefix is set' + required: false + default: 'artifacts' + keystore_base64: + description: 'Base64-encoded Android release keystore file' + required: false + default: '' + key_alias: + description: 'Android signing key alias' + required: false + default: '' + key_password: + description: 'Android signing key password' + required: false + default: '' + store_password: + description: 'Android keystore password' + required: false + default: '' + google_play_service_account_json: + description: 'Google Play service account JSON content for Play Store upload' + required: false + default: '' + google_play_service_account_file: + description: 'Fallback path to Google Play service account JSON file (relative to repo root)' + required: false + default: '' + google_play_track: + description: 'Google Play track to deploy to (internal, alpha, beta, production)' + required: false + default: 'beta' + google_play_status: + description: 'Google Play release status (completed, draft, inProgress, halted)' + required: false + default: 'completed' + google_play_script: + description: 'Path to the Dart Google Play upload script (relative to repo root)' + required: false + default: 'scripts/upload_play_store.dart' + shorebird_token: + description: 'Shorebird token for optional Shorebird release' + required: false + default: '' + shorebird_artifact: + description: 'Shorebird artifact type (apk or aab)' + required: false + default: 'apk' + +outputs: + version: + description: 'Resolved package version from pubspec.yaml' + value: ${{ steps.build.outputs.version }} + aab_path: + description: 'Path to the built AAB file' + value: ${{ steps.build.outputs.aab_path }} + apk_path: + description: 'Path to the built APK file' + value: ${{ steps.build.outputs.apk_path }} + +runs: + using: 'composite' + steps: + - name: Configure Android Signing Keystore + shell: bash + env: + WORK_DIR: ${{ inputs.working_directory }} + KEYSTORE_BASE64: ${{ inputs.keystore_base64 }} + KEY_ALIAS: ${{ inputs.key_alias }} + KEY_PASSWORD: ${{ inputs.key_password }} + STORE_PASSWORD: ${{ inputs.store_password }} + run: | + if [ -n "$KEYSTORE_BASE64" ]; then + echo "Decoding Android Keystore..." + mkdir -p "$WORK_DIR/android/app" + echo "$KEYSTORE_BASE64" | base64 --decode > "$WORK_DIR/android/app/release.keystore" + printf "storePassword=%s\nkeyPassword=%s\nkeyAlias=%s\nstoreFile=release.keystore\n" \ + "$STORE_PASSWORD" "$KEY_PASSWORD" "$KEY_ALIAS" > "$WORK_DIR/android/key.properties" + echo "Configured $WORK_DIR/android/key.properties" + else + echo "Notice: keystore_base64 not configured. Building unsigned / default debug signing." + fi + + - name: Build Android Release Artifacts + id: build + shell: bash + env: + WORK_DIR: ${{ inputs.working_directory }} + BUILD_AAB: ${{ inputs.build_aab }} + BUILD_APK: ${{ inputs.build_apk }} + BUILD_ARGS: ${{ inputs.build_args }} + ARTIFACT_PREFIX: ${{ inputs.artifact_prefix }} + OUTPUT_DIR: ${{ inputs.output_dir }} + run: | + 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" + + cd "$WORK_DIR" + if [ "$BUILD_AAB" = "true" ]; then + echo "Building Android App Bundle (AAB)..." + # shellcheck disable=SC2086 + flutter build appbundle --release $BUILD_ARGS + fi + + if [ "$BUILD_APK" = "true" ]; then + echo "Building Android APK..." + # shellcheck disable=SC2086 + flutter build apk --release $BUILD_ARGS + fi + cd "$ROOT_DIR" + + AAB_REL="$WORK_DIR/build/app/outputs/bundle/release/app-release.aab" + APK_REL="$WORK_DIR/build/app/outputs/flutter-apk/app-release.apk" + AAB_REL="${AAB_REL#./}" + APK_REL="${APK_REL#./}" + + if [ -n "$ARTIFACT_PREFIX" ]; then + mkdir -p "$OUTPUT_DIR" + if [ -f "$AAB_REL" ]; then + cp "$AAB_REL" "$OUTPUT_DIR/${ARTIFACT_PREFIX}-v${VERSION}.aab" + AAB_OUT="$OUTPUT_DIR/${ARTIFACT_PREFIX}-v${VERSION}.aab" + fi + if [ -f "$APK_REL" ]; then + cp "$APK_REL" "$OUTPUT_DIR/${ARTIFACT_PREFIX}-v${VERSION}.apk" + APK_OUT="$OUTPUT_DIR/${ARTIFACT_PREFIX}-v${VERSION}.apk" + fi + echo "Collected Android artifacts for version $VERSION in $OUTPUT_DIR" + fi + + echo "aab_path=${AAB_OUT:-$AAB_REL}" >> "$GITHUB_OUTPUT" + echo "apk_path=${APK_OUT:-$APK_REL}" >> "$GITHUB_OUTPUT" + + - name: Upload AAB to Google Play Console + shell: bash + env: + WORK_DIR: ${{ inputs.working_directory }} + PLAY_STORE_JSON: ${{ inputs.google_play_service_account_json }} + PLAY_STORE_FILE: ${{ inputs.google_play_service_account_file }} + PLAY_TRACK: ${{ inputs.google_play_track }} + PLAY_STATUS: ${{ inputs.google_play_status }} + PLAY_SCRIPT: ${{ inputs.google_play_script }} + run: | + AAB_FILE="$WORK_DIR/build/app/outputs/bundle/release/app-release.aab" + AAB_FILE="${AAB_FILE#./}" + if [ ! -f "$AAB_FILE" ]; then + echo "No AAB file found at $AAB_FILE; skipping Google Play upload." + exit 0 + fi + + DEFAULT_SA_FILE="$WORK_DIR/android/app/google_play.json" + DEFAULT_SA_FILE="${DEFAULT_SA_FILE#./}" + SA_FILE="${PLAY_STORE_FILE:-$DEFAULT_SA_FILE}" + + if [ -n "$PLAY_STORE_JSON" ] && [ -f "$PLAY_SCRIPT" ]; then + echo "Google Play service account secret detected. Uploading AAB to track '$PLAY_TRACK'..." + echo "$PLAY_STORE_JSON" > /tmp/google_play_service_account.json + dart "$PLAY_SCRIPT" \ + --aab "$AAB_FILE" \ + --service-account /tmp/google_play_service_account.json \ + --track "$PLAY_TRACK" \ + --status "$PLAY_STATUS" + rm -f /tmp/google_play_service_account.json + elif [ -f "$SA_FILE" ] && [ -f "$PLAY_SCRIPT" ]; then + echo "Using repository service account file ($SA_FILE) for Google Play upload to track '$PLAY_TRACK'..." + dart "$PLAY_SCRIPT" \ + --aab "$AAB_FILE" \ + --service-account "$SA_FILE" \ + --track "$PLAY_TRACK" \ + --status "$PLAY_STATUS" + else + echo "Skipping Google Play upload (credentials or upload script not configured)." + fi + + - name: Release via Shorebird (Optional) + if: inputs.shorebird_token != '' + shell: bash + env: + WORK_DIR: ${{ inputs.working_directory }} + SHOREBIRD_TOKEN: ${{ inputs.shorebird_token }} + SHOREBIRD_ARTIFACT: ${{ inputs.shorebird_artifact }} + run: | + if [ -n "$SHOREBIRD_TOKEN" ]; then + ROOT_DIR="$(pwd)" + cd "$WORK_DIR" + flutter pub global activate shorebird_cli + shorebird release android --artifact="$SHOREBIRD_ARTIFACT" + cd "$ROOT_DIR" + fi diff --git a/flutter-build-ios/README.md b/flutter-build-ios/README.md new file mode 100644 index 0000000..2359f69 --- /dev/null +++ b/flutter-build-ios/README.md @@ -0,0 +1,49 @@ +# Flutter Build iOS + +Composite action to install Apple signing certificates and provisioning profiles into an isolated temporary keychain, build a Flutter iOS `.ipa`, and optionally push to TestFlight and Shorebird. + +## Inputs + +| Name | Description | Required | Default | +| :--- | :--- | :--- | :--- | +| `working_directory` | Path to the Flutter app package directory. | No | `.` | +| `export_options_plist` | Relative path inside `working_directory` to `ExportOptions.plist`. | No | `ios/ExportOptions.plist` | +| `build_args` | Extra arguments passed to `flutter build ipa / ios`. | No | `''` | +| `disable_spm` | Disable Flutter Swift Package Manager before building. | No | `true` | +| `artifact_prefix` | Optional prefix for copied versioned IPA in `output_dir`. | No | `''` | +| `output_dir` | Directory to copy versioned IPA into when `artifact_prefix` is set. | No | `artifacts` | +| `certificate_base64` | Base64-encoded Apple iOS distribution `.p12` certificate. | No | `''` | +| `certificate_password` | Password for the Apple `.p12` certificate. | No | `''` | +| `provisioning_profile_base64` | Base64-encoded `.mobileprovision` profile. | No | `''` | +| `asc_key_id` | App Store Connect API Key ID. | No | `''` | +| `asc_issuer_id` | App Store Connect API Issuer ID. | No | `''` | +| `asc_private_key` | App Store Connect API Private Key (`.p8` content). | No | `''` | +| `apple_id_username` | Fallback Apple ID email for `altool` upload. | No | `''` | +| `apple_id_password` | Fallback Apple ID app-specific password for `altool` upload. | No | `''` | +| `upload_to_testflight` | Whether to upload signed IPA to TestFlight when credentials are provided. | No | `true` | +| `shorebird_token` | Shorebird token for optional Shorebird iOS release. | No | `''` | + +## Outputs + +| Name | Description | +| :--- | :--- | +| `version` | Resolved package version from `pubspec.yaml`. | +| `ipa_path` | Path to the built or packaged `.ipa` file. | +| `signed` | `'true'` if a signed App Store IPA was exported, `'false'` otherwise. | + +## Example Usage + +```yaml +- name: Build & Release iOS + uses: rodydavis/shared-actions/flutter-build-ios@main + with: + working_directory: packages/hfw_flutter + artifact_prefix: hfw-ios + certificate_base64: ${{ secrets.IOS_CERTIFICATE_BASE64 }} + certificate_password: ${{ secrets.IOS_CERTIFICATE_PWD }} + provisioning_profile_base64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }} + asc_key_id: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + asc_issuer_id: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + asc_private_key: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + shorebird_token: ${{ secrets.SHOREBIRD_TOKEN }} +``` diff --git a/flutter-build-ios/action.yml b/flutter-build-ios/action.yml new file mode 100644 index 0000000..511b215 --- /dev/null +++ b/flutter-build-ios/action.yml @@ -0,0 +1,244 @@ +name: 'Flutter Build iOS' +description: 'Installs Apple signing certificates and provisioning profiles in an isolated keychain, builds an iOS release IPA, and optionally uploads to TestFlight and Shorebird.' +inputs: + working_directory: + description: 'Path to the Flutter app package directory (e.g., packages/hfw_flutter or .)' + required: false + default: '.' + export_options_plist: + description: 'Relative path inside working_directory to ExportOptions.plist' + required: false + default: 'ios/ExportOptions.plist' + build_args: + description: 'Extra arguments passed to flutter build ipa / ios (e.g., --dart-define=...)' + required: false + default: '' + disable_spm: + description: 'Disable Flutter Swift Package Manager before building' + required: false + default: 'true' + artifact_prefix: + description: 'Optional prefix for copied versioned IPA in output_dir (e.g., hfw-ios -> hfw-ios-v1.0.0.ipa)' + required: false + default: '' + output_dir: + description: 'Directory to copy versioned IPA into when artifact_prefix is set' + required: false + default: 'artifacts' + certificate_base64: + description: 'Base64-encoded Apple iOS distribution .p12 certificate' + required: false + default: '' + certificate_password: + description: 'Password for the Apple .p12 certificate' + required: false + default: '' + provisioning_profile_base64: + description: 'Base64-encoded .mobileprovision profile' + required: false + default: '' + asc_key_id: + description: 'App Store Connect API Key ID' + required: false + default: '' + asc_issuer_id: + description: 'App Store Connect API Issuer ID' + required: false + default: '' + asc_private_key: + description: 'App Store Connect API Private Key (.p8 content)' + required: false + default: '' + apple_id_username: + description: 'Fallback Apple ID email for altool upload' + required: false + default: '' + apple_id_password: + description: 'Fallback Apple ID app-specific password for altool upload' + required: false + default: '' + upload_to_testflight: + description: 'Whether to upload signed IPA to TestFlight when credentials are provided' + required: false + default: 'true' + shorebird_token: + description: 'Shorebird token for optional Shorebird iOS release' + required: false + default: '' + +outputs: + version: + description: 'Resolved package version from pubspec.yaml' + value: ${{ steps.build.outputs.version }} + ipa_path: + description: 'Path to the built or packaged IPA file' + value: ${{ steps.build.outputs.ipa_path }} + signed: + description: 'Whether a signed IPA was exported by Xcode' + value: ${{ steps.build.outputs.signed }} + +runs: + using: 'composite' + steps: + - name: Install Apple Certificate and Provisioning Profile + shell: bash + env: + IOS_CERT_BASE64: ${{ inputs.certificate_base64 }} + IOS_CERT_PWD: ${{ inputs.certificate_password }} + IOS_PROFILE_BASE64: ${{ inputs.provisioning_profile_base64 }} + run: | + if [ -n "$IOS_CERT_BASE64" ]; then + echo "Setting up temporary keychain for iOS signing..." + security delete-keychain signing_temp.keychain 2>/dev/null || true + rm -f "$HOME/Library/Keychains/signing_temp.keychain-db" || true + + KEYCHAIN="$HOME/Library/Keychains/signing_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 "$KEYCHAIN" "$HOME/Library/Keychains/login.keychain-db" || true + security default-keychain -s "$HOME/Library/Keychains/login.keychain-db" || true + + echo "$IOS_CERT_BASE64" | base64 --decode > /tmp/certificate.p12 + security import "/tmp/certificate.p12" -k "$KEYCHAIN" -P "$IOS_CERT_PWD" -T /usr/bin/codesign -T /usr/bin/security -A || true + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PWD" "$KEYCHAIN" || true + + if [ -n "$IOS_PROFILE_BASE64" ]; then + mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" + echo "$IOS_PROFILE_BASE64" | base64 --decode > /tmp/profile.mobileprovision + PROFILE_UUID=$(security cms -D -i /tmp/profile.mobileprovision 2>/dev/null | plutil -extract UUID raw - 2>/dev/null || echo "profile") + cp /tmp/profile.mobileprovision "$HOME/Library/MobileDevice/Provisioning Profiles/${PROFILE_UUID}.mobileprovision" + cp /tmp/profile.mobileprovision "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/${PROFILE_UUID}.mobileprovision" + fi + echo "iOS signing credentials installed successfully." + else + echo "Notice: certificate_base64 not configured." + fi + + - name: Build iOS IPA + id: build + shell: bash + env: + WORK_DIR: ${{ inputs.working_directory }} + EXPORT_PLIST: ${{ inputs.export_options_plist }} + BUILD_ARGS: ${{ inputs.build_args }} + DISABLE_SPM: ${{ inputs.disable_spm }} + ARTIFACT_PREFIX: ${{ inputs.artifact_prefix }} + OUTPUT_DIR: ${{ inputs.output_dir }} + IOS_CERT_BASE64: ${{ inputs.certificate_base64 }} + ASC_KEY_ID: ${{ inputs.asc_key_id }} + ASC_ISSUER_ID: ${{ inputs.asc_issuer_id }} + ASC_KEY_CONTENT: ${{ inputs.asc_private_key }} + run: | + export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$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 [ -n "$ASC_KEY_ID" ] && [ -n "$ASC_KEY_CONTENT" ]; then + mkdir -p "$HOME/.appstoreconnect/private_keys" + echo "$ASC_KEY_CONTENT" > "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" + fi + + cd "$WORK_DIR" + if [ "$DISABLE_SPM" = "true" ]; then + flutter config --no-enable-swift-package-manager + fi + + if [ -f "$EXPORT_PLIST" ] && [ -n "$IOS_CERT_BASE64" ]; then + # shellcheck disable=SC2086 + flutter build ipa --release --export-options-plist="$EXPORT_PLIST" $BUILD_ARGS || flutter build ios --release --no-codesign $BUILD_ARGS + else + # shellcheck disable=SC2086 + flutter build ios --release --no-codesign $BUILD_ARGS + fi + cd "$ROOT_DIR" + + SIGNED_IPA=$(ls "$WORK_DIR"/build/ios/ipa/*.ipa 2>/dev/null | head -n 1 || true) + if [ -n "$SIGNED_IPA" ]; then + echo "signed=true" >> "$GITHUB_OUTPUT" + else + echo "signed=false" >> "$GITHUB_OUTPUT" + fi + + IPA_OUT="${SIGNED_IPA#./}" + if [ -n "$ARTIFACT_PREFIX" ]; then + mkdir -p "$OUTPUT_DIR" + TARGET_IPA="$OUTPUT_DIR/${ARTIFACT_PREFIX}-v${VERSION}.ipa" + if [ -n "$SIGNED_IPA" ]; then + cp "$SIGNED_IPA" "$TARGET_IPA" + elif [ -d "$WORK_DIR/build/ios/iphoneos/Runner.app" ]; then + rm -rf /tmp/Payload + mkdir -p /tmp/Payload + cp -r "$WORK_DIR/build/ios/iphoneos/Runner.app" /tmp/Payload/ + (cd /tmp && zip -qr "$ROOT_DIR/$TARGET_IPA" Payload) + rm -rf /tmp/Payload + else + touch "$TARGET_IPA" + fi + IPA_OUT="$TARGET_IPA" + echo "Collected iOS artifact at $IPA_OUT" + fi + + echo "ipa_path=$IPA_OUT" >> "$GITHUB_OUTPUT" + + - name: Push to TestFlight via App Store Connect API + if: inputs.upload_to_testflight == 'true' + 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 }} + AC_USERNAME: ${{ inputs.apple_id_username }} + AC_PASSWORD: ${{ inputs.apple_id_password }} + run: | + IPA_FILE=$(ls "$WORK_DIR"/build/ios/ipa/*.ipa 2>/dev/null | head -n 1 || true) + if [ -z "$IPA_FILE" ]; then + echo "Skipping TestFlight upload: no signed App Store IPA was exported in $WORK_DIR/build/ios/ipa/." + exit 0 + fi + if [ -n "$ASC_KEY_ID" ] && [ -n "$ASC_ISSUER_ID" ] && [ -n "$ASC_KEY_CONTENT" ]; then + echo "Uploading signed IPA ($IPA_FILE) to TestFlight via App Store Connect API Key..." + mkdir -p "$HOME/.appstoreconnect/private_keys" + echo "$ASC_KEY_CONTENT" > "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" + xcrun altool --upload-app \ + -f "$IPA_FILE" \ + --type ios \ + --apiKey "$ASC_KEY_ID" \ + --apiIssuer "$ASC_ISSUER_ID" + elif [ -n "$AC_USERNAME" ] && [ -n "$AC_PASSWORD" ]; then + echo "Uploading signed IPA ($IPA_FILE) to TestFlight via Apple ID App-Specific Password..." + xcrun altool --upload-app \ + -f "$IPA_FILE" \ + --type ios \ + --u "$AC_USERNAME" \ + --p "$AC_PASSWORD" + else + echo "Skipping TestFlight upload (App Store Connect credentials not configured)." + fi + + - name: Release via Shorebird (Optional) + if: inputs.shorebird_token != '' + shell: bash + env: + WORK_DIR: ${{ inputs.working_directory }} + SHOREBIRD_TOKEN: ${{ inputs.shorebird_token }} + run: | + if [ -n "$SHOREBIRD_TOKEN" ]; then + ROOT_DIR="$(pwd)" + cd "$WORK_DIR" + flutter pub global activate shorebird_cli + shorebird release ios --artifact=ipa + cd "$ROOT_DIR" + fi + + - name: Clean up keychain + if: always() + 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_temp.keychain-db" 2>/dev/null || true + rm -f "$HOME/Library/Keychains/signing_temp.keychain-db" /tmp/certificate.p12 /tmp/profile.mobileprovision || true diff --git a/flutter-build-linux/README.md b/flutter-build-linux/README.md new file mode 100644 index 0000000..61618e5 --- /dev/null +++ b/flutter-build-linux/README.md @@ -0,0 +1,43 @@ +# Flutter Build Linux + +Composite action to install Linux GTK3 build dependencies, compile Linux Flutter desktop applications, package `.deb` and `.tar.gz` installers, and generate release metadata. + +## Inputs + +| Name | Description | Required | Default | +| :--- | :--- | :--- | :--- | +| `working_directory` | Path to the Flutter app package directory. | No | `.` | +| `arch` | Target Linux architecture (`amd64` / `x64` or `arm64`). | No | `amd64` | +| `build_args` | Extra arguments passed to `flutter build linux`. | No | `''` | +| `install_deps` | Install Linux GTK3 & CMake build dependencies via `apt-get`. | No | `true` | +| `auto_create_linux_runner` | Automatically run `flutter create --platforms=linux .` if `linux/` directory is missing. | No | `true` | +| `output_dir` | Output directory for packaged `.deb` and `.tar.gz` archives. | No | `dist` | +| `package_script` | Optional custom packaging script in repository (e.g., `scripts/package_linux.sh`). | No | `scripts/package_linux.sh` | +| `app_name` | Application display name for `.desktop` entry. | No | `Slides for Worship` | +| `package_name` | Debian package slug (e.g., `slides-for-worship`). | No | `slides-for-worship` | +| `binary_name` | Compiled Linux executable name inside bundle (auto-detected if empty). | No | `''` | +| `icon_path` | Path to app icon PNG relative to `working_directory`. | No | `assets/icon.png` | +| `maintainer` | Debian package maintainer string. | No | `Rody Davis ` | +| `description` | Debian package description. | No | `Professional worship slide production and presentation studio.` | +| `generate_metadata_json` | Whether to generate `linux__metadata.json`. | No | `false` | + +## Outputs + +| Name | Description | +| :--- | :--- | +| `version` | Resolved package version from `pubspec.yaml`. | +| `deb_path` | Path to generated `.deb` package. | +| `tar_path` | Path to generated `.tar.gz` archive. | +| `metadata_path` | Path to generated `linux__metadata.json`. | + +## Example Usage + +```yaml +- name: Build & Package Linux Release + uses: rodydavis/shared-actions/flutter-build-linux@main + with: + working_directory: packages/sfw_desktop + arch: ${{ matrix.arch }} + build_args: --dart-define=API_BASE_URL=https://slidesforworship.app + generate_metadata_json: 'true' +``` diff --git a/flutter-build-linux/action.yml b/flutter-build-linux/action.yml new file mode 100644 index 0000000..9a16f2b --- /dev/null +++ b/flutter-build-linux/action.yml @@ -0,0 +1,230 @@ +name: 'Flutter Build Linux' +description: 'Installs GTK3/Linux build dependencies, builds Linux Flutter desktop apps, packages .tar.gz and .deb installers, and generates release metadata.' +inputs: + working_directory: + description: 'Path to the Flutter app package directory (e.g., packages/sfw_desktop)' + required: false + default: '.' + arch: + description: 'Target Linux architecture (amd64 / x64 or arm64)' + required: false + default: 'amd64' + build_args: + description: 'Extra arguments passed to flutter build linux (e.g., --dart-define=...)' + required: false + default: '' + install_deps: + description: 'Install Linux GTK3 & CMake build dependencies via apt-get' + required: false + default: 'true' + auto_create_linux_runner: + description: 'Automatically run flutter create --platforms=linux . if linux/ directory is missing' + required: false + default: 'true' + output_dir: + description: 'Output directory for packaged .deb and .tar.gz archives' + required: false + default: 'dist' + package_script: + description: 'Optional custom packaging script in repository (e.g., scripts/package_linux.sh)' + required: false + default: 'scripts/package_linux.sh' + app_name: + description: 'Application display name for .desktop entry (used if package_script is not present)' + required: false + default: 'Slides for Worship' + package_name: + description: 'Debian package slug (e.g., slides-for-worship)' + required: false + default: 'slides-for-worship' + binary_name: + description: 'Compiled Linux executable name inside bundle (auto-detected if empty)' + required: false + default: '' + icon_path: + description: 'Path to app icon PNG relative to working_directory' + required: false + default: 'assets/icon.png' + maintainer: + description: 'Debian package maintainer string' + required: false + default: 'Rody Davis ' + description: + description: 'Debian package description' + required: false + default: 'Professional worship slide production and presentation studio.' + generate_metadata_json: + description: 'Whether to generate linux__metadata.json' + required: false + default: 'false' + +outputs: + version: + description: 'Resolved package version from pubspec.yaml' + value: ${{ steps.build.outputs.version }} + deb_path: + description: 'Path to generated .deb package' + value: ${{ steps.package.outputs.deb_path }} + tar_path: + description: 'Path to generated .tar.gz archive' + value: ${{ steps.package.outputs.tar_path }} + metadata_path: + description: 'Path to generated linux__metadata.json' + value: ${{ steps.package.outputs.metadata_path }} + +runs: + using: 'composite' + steps: + - name: Install Linux Build Dependencies + if: inputs.install_deps == 'true' && runner.os == 'Linux' + shell: bash + run: | + if command -v apt-get >/dev/null 2>&1; then + SUDO="" + if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + fi + $SUDO apt-get update + $SUDO apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev dpkg-dev + fi + + - name: Build Linux Release + id: build + shell: bash + env: + WORK_DIR: ${{ inputs.working_directory }} + ARCH: ${{ inputs.arch }} + BUILD_ARGS: ${{ inputs.build_args }} + AUTO_CREATE: ${{ inputs.auto_create_linux_runner }} + run: | + 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" + + cd "$WORK_DIR" + flutter config --enable-linux-desktop + + if [ ! -d "linux" ] && [ "$AUTO_CREATE" = "true" ]; then + echo "Notice: '$WORK_DIR/linux' directory not found. Scaffolding Linux runner via 'flutter create --platforms=linux .'..." + flutter create --platforms=linux . + fi + + if [ "$ARCH" = "arm64" ] || [ "$ARCH" = "aarch64" ]; then + # shellcheck disable=SC2086 + flutter build linux --release --target-platform=linux-arm64 $BUILD_ARGS + else + # shellcheck disable=SC2086 + flutter build linux --release $BUILD_ARGS + fi + cd "$ROOT_DIR" + + - name: Package Linux Installers (.deb & .tar.gz) & Metadata + id: package + shell: bash + env: + WORK_DIR: ${{ inputs.working_directory }} + ARCH_INPUT: ${{ inputs.arch }} + OUT_DIR: ${{ inputs.output_dir }} + PKG_SCRIPT: ${{ inputs.package_script }} + APP_NAME: ${{ inputs.app_name }} + PKG_NAME: ${{ inputs.package_name }} + BIN_NAME: ${{ inputs.binary_name }} + ICON_REL: ${{ inputs.icon_path }} + MAINTAINER: ${{ inputs.maintainer }} + DESC: ${{ inputs.description }} + VERSION: ${{ steps.build.outputs.version }} + GEN_META: ${{ inputs.generate_metadata_json }} + run: | + ROOT_DIR="$(pwd)" + if [ "$ARCH_INPUT" = "arm64" ] || [ "$ARCH_INPUT" = "aarch64" ]; then + DEB_ARCH="arm64" + BUNDLE_DIR="$WORK_DIR/build/linux/arm64/release/bundle" + else + DEB_ARCH="amd64" + BUNDLE_DIR="$WORK_DIR/build/linux/x64/release/bundle" + fi + + mkdir -p "$OUT_DIR" + + if [ -n "$PKG_SCRIPT" ] && [ -f "$PKG_SCRIPT" ]; then + echo "Running repository packaging script: $PKG_SCRIPT..." + bash "$PKG_SCRIPT" "$VERSION" "$DEB_ARCH" "$BUNDLE_DIR" + else + echo "Packaging .tar.gz and .deb directly..." + TAR_NAME="${PKG_NAME}-linux-${DEB_ARCH}.tar.gz" + tar -czf "$OUT_DIR/$TAR_NAME" -C "$BUNDLE_DIR" . + + DEB_DIR="$WORK_DIR/build/deb_pkg_${DEB_ARCH}" + rm -rf "$DEB_DIR" + mkdir -p "$DEB_DIR/DEBIAN" + mkdir -p "$DEB_DIR/usr/bin" + mkdir -p "$DEB_DIR/usr/share/${PKG_NAME}" + mkdir -p "$DEB_DIR/usr/share/applications" + mkdir -p "$DEB_DIR/usr/share/icons/hicolor/512x512/apps" + + cp -r "$BUNDLE_DIR"/* "$DEB_DIR/usr/share/${PKG_NAME}/" + + if [ -z "$BIN_NAME" ]; then + BIN_NAME=$(grep '^name: ' "$WORK_DIR/pubspec.yaml" | head -n 1 | cut -d ' ' -f 2 | tr -d '\r') + fi + ln -sf "/usr/share/${PKG_NAME}/${BIN_NAME}" "$DEB_DIR/usr/bin/${PKG_NAME}" + + if [ -f "$WORK_DIR/$ICON_REL" ]; then + cp "$WORK_DIR/$ICON_REL" "$DEB_DIR/usr/share/icons/hicolor/512x512/apps/${PKG_NAME}.png" + fi + + cat < "$DEB_DIR/usr/share/applications/${PKG_NAME}.desktop" + [Desktop Entry] + Name=$APP_NAME + Comment=$DESC + Exec=/usr/bin/$PKG_NAME + Icon=$PKG_NAME + Terminal=false + Type=Application + Categories=AudioVideo;Presentation;Utility; + EOF + + CLEAN_VERSION=$(echo "$VERSION" | cut -d'+' -f1) + cat < "$DEB_DIR/DEBIAN/control" + Package: $PKG_NAME + Version: $CLEAN_VERSION + Section: utils + Priority: optional + Architecture: $DEB_ARCH + Maintainer: $MAINTAINER + Description: $APP_NAME + $DESC + EOF + + dpkg-deb --build "$DEB_DIR" "$OUT_DIR/${PKG_NAME}-linux-${DEB_ARCH}.deb" + fi + + DEB_FILE=$(find "$OUT_DIR" -name "*-linux-${DEB_ARCH}.deb" 2>/dev/null | head -n 1 || true) + TAR_FILE=$(find "$OUT_DIR" -name "*-linux-${DEB_ARCH}.tar.gz" 2>/dev/null | head -n 1 || true) + echo "deb_path=$DEB_FILE" >> "$GITHUB_OUTPUT" + echo "tar_path=$TAR_FILE" >> "$GITHUB_OUTPUT" + + if [ "$GEN_META" = "true" ]; then + PRIMARY_FILE="${DEB_FILE:-$TAR_FILE}" + SIZE="0" + FILENAME="${PKG_NAME}-linux-${DEB_ARCH}.deb" + if [ -n "$PRIMARY_FILE" ] && [ -f "$PRIMARY_FILE" ]; then + SIZE=$(stat -c%s "$PRIMARY_FILE" 2>/dev/null || stat -f%z "$PRIMARY_FILE" 2>/dev/null || echo "0") + FILENAME=$(basename "$PRIMARY_FILE") + fi + + META_FILE="linux_${DEB_ARCH}_metadata.json" + cat < "$META_FILE" + { + "version": "$VERSION", + "platform": "linux", + "arch": "$DEB_ARCH", + "filename": "$FILENAME", + "size": $SIZE, + "signature": "", + "signatureType": "none" + } + EOF + cat "$META_FILE" + echo "metadata_path=$META_FILE" >> "$GITHUB_OUTPUT" + fi diff --git a/flutter-build-macos/README.md b/flutter-build-macos/README.md new file mode 100644 index 0000000..6abfb38 --- /dev/null +++ b/flutter-build-macos/README.md @@ -0,0 +1,63 @@ +# Flutter Build macOS + +Composite action to build, sign (Developer ID & Sparkle), package (`.dmg` or `.zip`), notarize, and generate auto-updater metadata for macOS Flutter desktop applications. + +## Inputs + +| Name | Description | Required | Default | +| :--- | :--- | :--- | :--- | +| `working_directory` | Path to the Flutter app package directory. | No | `.` | +| `app_name` | Display name of the `.app` bundle (auto-detected if empty). | No | `''` | +| `build_args` | Extra arguments passed to `flutter build macos`. | No | `''` | +| `disable_spm` | Disable Flutter Swift Package Manager before building. | No | `true` | +| `run_pod_install` | Run `pod install` inside `working_directory/macos` before building. | No | `false` | +| `package_type` | Packaging format to produce: `dmg`, `zip`, or `both`. | No | `dmg` | +| `dmg_filename` | Output DMG filename. | No | `''` | +| `zip_filename` | Output ZIP path/filename when `package_type` is `zip` or `both`. | No | `''` | +| `entitlements_path` | Path to entitlements plist relative to `working_directory`. | No | `macos/Runner/Release.entitlements` | +| `signing_identity` | Fallback Developer ID Application signing identity. | No | `''` | +| `certificate_base64` | Base64-encoded Apple `.p12` certificate. | No | `''` | +| `certificate_password` | Password for the Apple `.p12` certificate. | No | `''` | +| `certificate_file` | Fallback path to local `.p12` certificate file in repository. | No | `signing/macos/certificate.p12` | +| `metadata_txt_file` | Fallback path to `metadata.txt` containing p12/notarization credentials. | No | `signing/macos/metadata.txt` | +| `provisioning_profile_base64` | Optional Base64-encoded `.provisionprofile`. | No | `''` | +| `notarize_apple_id` | Apple ID email for DMG notarization. | No | `''` | +| `notarize_password` | Apple ID App-Specific Password for DMG notarization. | No | `''` | +| `notarize_team_id` | Apple Developer 10-character Team ID for DMG notarization. | No | `''` | +| `sparkle_private_key` | Sparkle EdDSA private key content for signing DMG updates. | No | `''` | +| `sparkle_key_file` | Fallback path to Sparkle EdDSA private key file. | No | `signing/sparkle/sparkle_key.pem` | +| `generate_metadata_json` | Whether to generate `macos_metadata.json` and `macos_signature.txt`. | No | `false` | +| `asc_key_id` | Optional App Store Connect API Key ID for `.pkg` upload. | No | `''` | +| `asc_issuer_id` | Optional App Store Connect API Issuer ID for `.pkg` upload. | No | `''` | +| `asc_private_key` | Optional App Store Connect API Private Key (`.p8`) for `.pkg` upload. | No | `''` | + +## Outputs + +| Name | Description | +| :--- | :--- | +| `version` | Resolved package version from `pubspec.yaml`. | +| `dmg_path` | Path to generated DMG installer. | +| `zip_path` | Path to generated ZIP archive. | +| `signature` | Sparkle EdDSA signature if generated. | +| `metadata_path` | Path to generated `macos_metadata.json`. | + +## Example Usage + +```yaml +- name: Build & Package macOS Release + uses: rodydavis/shared-actions/flutter-build-macos@main + with: + working_directory: packages/sfw_desktop + app_name: Slides for Worship + package_type: dmg + dmg_filename: slides-for-worship-macos-universal.dmg + run_pod_install: 'true' + build_args: --dart-define=API_BASE_URL=https://slidesforworship.app + certificate_base64: ${{ secrets.MACOS_CERTIFICATE }} + certificate_password: ${{ secrets.MACOS_CERTIFICATE_PWD }} + notarize_apple_id: ${{ secrets.AC_USERNAME }} + notarize_password: ${{ secrets.AC_PASSWORD }} + notarize_team_id: ${{ secrets.AC_TEAM_ID }} + sparkle_private_key: ${{ secrets.SPARKLE_PRIVATE_KEY }} + generate_metadata_json: 'true' +``` diff --git a/flutter-build-macos/action.yml b/flutter-build-macos/action.yml new file mode 100644 index 0000000..533d69a --- /dev/null +++ b/flutter-build-macos/action.yml @@ -0,0 +1,463 @@ +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 diff --git a/flutter-build-windows/README.md b/flutter-build-windows/README.md new file mode 100644 index 0000000..99230e4 --- /dev/null +++ b/flutter-build-windows/README.md @@ -0,0 +1,48 @@ +# Flutter Build Windows + +Composite action to build Windows Flutter desktop applications, compile Inno Setup (`.exe`) and MSIX (`.msix`) installers, sign updates with WinSparkle DSA keys, and generate release metadata. + +## Inputs + +| Name | Description | Required | Default | +| :--- | :--- | :--- | :--- | +| `working_directory` | Path to the Flutter app package directory. | No | `.` | +| `arch` | Target Windows architecture (`x64` or `arm64`). | No | `x64` | +| `build_args` | Extra arguments passed to `flutter build windows`. | No | `''` | +| `build_inno_setup` | Whether to compile an Inno Setup installer (`.exe`). | No | `true` | +| `inno_setup_script` | Path to Inno Setup `.iss` script relative to `working_directory`. | No | `windows/installer/inno_setup.iss` | +| `output_dir` | Output directory for compiled Inno Setup installer. | No | `dist` | +| `exe_filename` | Expected installer filename for metadata JSON. | No | `''` | +| `build_msix` | Whether to build an MSIX package using `msix:create`. | No | `true` | +| `msix_output_name` | Output base name for the MSIX package. | No | `''` | +| `pfx_base64` | Base64-encoded Windows Authenticode `.pfx` certificate. | No | `''` | +| `pfx_password` | Password for the Windows `.pfx` certificate. | No | `''` | +| `sparkle_dsa_private_key` | WinSparkle DSA private key content (PEM) for signing installer. | No | `''` | +| `sparkle_dsa_key_file` | Fallback path to WinSparkle DSA private key file in repository. | No | `signing/sparkle/dsa_priv.pem` | +| `generate_metadata_json` | Whether to generate `windows__metadata.json` and `windows_signature.txt`. | No | `false` | + +## Outputs + +| Name | Description | +| :--- | :--- | +| `version` | Resolved package version from `pubspec.yaml`. | +| `exe_path` | Path to compiled Inno Setup `.exe` installer. | +| `msix_path` | Path to generated `.msix` installer. | +| `signature` | WinSparkle DSA signature if generated. | +| `metadata_path` | Path to generated `windows__metadata.json`. | + +## Example Usage + +```yaml +- name: Build & Package Windows Release + uses: rodydavis/shared-actions/flutter-build-windows@main + with: + working_directory: packages/sfw_desktop + arch: ${{ matrix.arch }} + build_args: --dart-define=API_BASE_URL=https://slidesforworship.app + msix_output_name: slides_for_worship_${{ matrix.arch }} + pfx_base64: ${{ secrets.WINDOWS_PFX_BASE64 }} + pfx_password: ${{ secrets.WINDOWS_PFX_PASSWORD }} + sparkle_dsa_private_key: ${{ secrets.SPARKLE_DSA_PRIVATE_KEY }} + generate_metadata_json: 'true' +``` diff --git a/flutter-build-windows/action.yml b/flutter-build-windows/action.yml new file mode 100644 index 0000000..9fda653 --- /dev/null +++ b/flutter-build-windows/action.yml @@ -0,0 +1,260 @@ +name: 'Flutter Build Windows' +description: 'Builds Windows Flutter desktop apps, compiles Inno Setup (.exe) and MSIX (.msix) installers, signs updates for WinSparkle (DSA), and generates release metadata.' +inputs: + working_directory: + description: 'Path to the Flutter app package directory (e.g., packages/sfw_desktop)' + required: false + default: '.' + arch: + description: 'Target Windows architecture (x64 or arm64)' + required: false + default: 'x64' + build_args: + description: 'Extra arguments passed to flutter build windows (e.g., --dart-define=...)' + required: false + default: '' + build_inno_setup: + description: 'Whether to compile an Inno Setup installer (.exe)' + required: false + default: 'true' + inno_setup_script: + description: 'Path to Inno Setup .iss script relative to working_directory' + required: false + default: 'windows/installer/inno_setup.iss' + output_dir: + description: 'Output directory for compiled Inno Setup installer' + required: false + default: 'dist' + exe_filename: + description: 'Expected installer filename for metadata JSON (e.g., slides-for-worship-windows-x64-setup.exe)' + required: false + default: '' + build_msix: + description: 'Whether to build an MSIX package using msix:create' + required: false + default: 'true' + msix_output_name: + description: 'Output base name for the MSIX package (e.g., slides_for_worship_x64)' + required: false + default: '' + pfx_base64: + description: 'Base64-encoded Windows Authenticode .pfx certificate' + required: false + default: '' + pfx_password: + description: 'Password for the Windows .pfx certificate' + required: false + default: '' + sparkle_dsa_private_key: + description: 'WinSparkle DSA private key content (PEM) for signing installer' + required: false + default: '' + sparkle_dsa_key_file: + description: 'Fallback path to WinSparkle DSA private key file in repository' + required: false + default: 'signing/sparkle/dsa_priv.pem' + generate_metadata_json: + description: 'Whether to generate windows__metadata.json and windows_signature.txt' + required: false + default: 'false' + +outputs: + version: + description: 'Resolved package version from pubspec.yaml' + value: ${{ steps.build.outputs.version }} + exe_path: + description: 'Path to compiled Inno Setup .exe installer' + value: ${{ steps.package_meta.outputs.exe_path }} + msix_path: + description: 'Path to generated .msix installer' + value: ${{ steps.package_meta.outputs.msix_path }} + signature: + description: 'WinSparkle DSA signature if generated' + value: ${{ steps.package_meta.outputs.signature }} + metadata_path: + description: 'Path to generated windows__metadata.json' + value: ${{ steps.package_meta.outputs.metadata_path }} + +runs: + using: 'composite' + steps: + - name: Configure Windows Tooling PATH + shell: powershell + run: | + $toolPaths = @( + "C:\Program Files\Git\bin", + "C:\Program Files\Git\usr\bin", + "C:\Git\bin", + "C:\Git\usr\bin", + "C:\Users\rodyd\AppData\Local\Programs\Inno Setup 6", + "C:\Program Files (x86)\Inno Setup 6", + "C:\Program Files\Inno Setup 6", + "C:\flutter\bin" + ) + foreach ($p in $toolPaths) { + if (Test-Path $p) { + $env:PATH = "$p;$env:PATH" + if (Test-Path env:GITHUB_PATH) { + [System.IO.File]::AppendAllText($env:GITHUB_PATH, "$p`r`n") + } + } + } + + - name: Decode Windows PFX Certificate + if: inputs.pfx_base64 != '' + shell: powershell + env: + WINDOWS_PFX_BASE64: ${{ inputs.pfx_base64 }} + run: | + if ($env:WINDOWS_PFX_BASE64) { + $pfx_cert_byte = [System.Convert]::FromBase64String($env:WINDOWS_PFX_BASE64) + [IO.File]::WriteAllBytes("cert.pfx", $pfx_cert_byte) + Write-Output "Decoded cert.pfx" + } + + - name: Build Windows Release + id: build + shell: bash + env: + WORK_DIR: ${{ inputs.working_directory }} + ARCH: ${{ inputs.arch }} + BUILD_ARGS: ${{ inputs.build_args }} + run: | + 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" + + cd "$WORK_DIR" + if [ "$ARCH" = "arm64" ]; then + # shellcheck disable=SC2086 + flutter build windows --release --target-platform=windows-arm64 $BUILD_ARGS + else + # shellcheck disable=SC2086 + flutter build windows --release $BUILD_ARGS + fi + cd "$ROOT_DIR" + + - name: Compile Inno Setup Installer + if: inputs.build_inno_setup == 'true' + shell: powershell + env: + WORK_DIR: ${{ inputs.working_directory }} + ARCH: ${{ inputs.arch }} + ISS_REL: ${{ inputs.inno_setup_script }} + OUT_DIR: ${{ inputs.output_dir }} + VERSION: ${{ steps.build.outputs.version }} + run: | + New-Item -ItemType Directory -Force -Path $env:OUT_DIR | Out-Null + $sourceRel = Join-Path $env:WORK_DIR "build\windows\$($env:ARCH)\runner\Release" + if (-not (Test-Path $sourceRel)) { + $sourceRel = Join-Path $env:WORK_DIR "build\windows\x64\runner\Release" + } + $sourceDir = (Resolve-Path $sourceRel).Path + $outputDir = (Resolve-Path $env:OUT_DIR).Path + $issScript = Join-Path $env:WORK_DIR $env:ISS_REL + + if (-not (Test-Path $issScript)) { + Write-Warning "Inno Setup script not found at $issScript; skipping Inno Setup compilation." + exit 0 + } + + $iscc = (Get-Command iscc.exe -ErrorAction SilentlyContinue).Source + if (-not $iscc -and (Test-Path "C:\Users\rodyd\AppData\Local\Programs\Inno Setup 6\ISCC.exe")) { + $iscc = "C:\Users\rodyd\AppData\Local\Programs\Inno Setup 6\ISCC.exe" + } + if ($iscc) { + & $iscc /DMyAppVersion="$($env:VERSION)" /DAppArch="$($env:ARCH)" /DSourceDir="$sourceDir" /DOutputDir="$outputDir" "$issScript" + } else { + Write-Warning "ISCC.exe not found on runner; skipping Inno Setup installer." + } + + - name: Create MSIX Package + if: inputs.build_msix == 'true' + shell: bash + env: + WORK_DIR: ${{ inputs.working_directory }} + ARCH: ${{ inputs.arch }} + MSIX_NAME: ${{ inputs.msix_output_name }} + PFX_PASS: ${{ inputs.pfx_password }} + run: | + ROOT_DIR="$(pwd)" + OUT_NAME="${MSIX_NAME:-slides_for_worship_${ARCH}}" + cd "$WORK_DIR" + if [ -f "$ROOT_DIR/cert.pfx" ]; then + flutter pub run msix:create --certificate-path "$ROOT_DIR/cert.pfx" --certificate-password "$PFX_PASS" --install-certificate false --output-name "$OUT_NAME" + else + flutter pub run msix:create --install-certificate false --output-name "$OUT_NAME" + fi + cd "$ROOT_DIR" + + - name: Sign for WinSparkle & Generate Metadata + id: package_meta + shell: bash + env: + WORK_DIR: ${{ inputs.working_directory }} + ARCH: ${{ inputs.arch }} + OUT_DIR: ${{ inputs.output_dir }} + VERSION: ${{ steps.build.outputs.version }} + EXE_FILENAME_INPUT: ${{ inputs.exe_filename }} + SPARKLE_DSA_KEY: ${{ inputs.sparkle_dsa_private_key }} + SPARKLE_DSA_FILE: ${{ inputs.sparkle_dsa_key_file }} + GEN_META: ${{ inputs.generate_metadata_json }} + run: | + EXE_FILE=$(find "$OUT_DIR" -name "*.exe" 2>/dev/null | head -n 1 || true) + MSIX_FILE=$(find "$WORK_DIR/build/windows" -name "*.msix" 2>/dev/null | head -n 1 || true) + echo "exe_path=$EXE_FILE" >> "$GITHUB_OUTPUT" + echo "msix_path=$MSIX_FILE" >> "$GITHUB_OUTPUT" + + SETUP_FILE="${EXE_FILE:-$MSIX_FILE}" + if [ -z "$SPARKLE_DSA_KEY" ] && [ -n "$SPARKLE_DSA_FILE" ] && [ -f "$SPARKLE_DSA_FILE" ]; then + SPARKLE_DSA_KEY=$(cat "$SPARKLE_DSA_FILE") + fi + + WIN_SIG="" + if [ -n "$SPARKLE_DSA_KEY" ] && [ -n "$SETUP_FILE" ] && [ -f "$SETUP_FILE" ]; then + echo "$SPARKLE_DSA_KEY" > dsa_priv.pem + echo "Signing $SETUP_FILE with DSA key..." + WIN_SIG=$(openssl dgst -sha1 -binary < "$SETUP_FILE" | openssl dgst -sha1 -sign dsa_priv.pem | openssl enc -base64 | tr -d '\n\r' || true) + if [ -n "$WIN_SIG" ]; then + echo "$WIN_SIG" > windows_signature.txt + echo "signature=$WIN_SIG" >> "$GITHUB_OUTPUT" + fi + rm -f dsa_priv.pem + fi + + if [ "$GEN_META" = "true" ]; then + SIZE="0" + if [ -n "$EXE_FILE" ] && [ -f "$EXE_FILE" ]; then + SIZE=$(stat -c%s "$EXE_FILE" 2>/dev/null || stat -f%z "$EXE_FILE" 2>/dev/null || echo "0") + fi + if [ -n "$EXE_FILENAME_INPUT" ]; then + FILENAME="$EXE_FILENAME_INPUT" + elif [ -n "$EXE_FILE" ]; then + FILENAME=$(basename "$EXE_FILE") + else + FILENAME="slides-for-worship-windows-${ARCH}-setup.exe" + fi + + META_FILE="windows_${ARCH}_metadata.json" + cat < "$META_FILE" + { + "version": "$VERSION", + "platform": "windows", + "arch": "$ARCH", + "filename": "$FILENAME", + "size": $SIZE, + "signature": "$WIN_SIG", + "signatureType": "dsaSignature" + } + EOF + cat "$META_FILE" + echo "metadata_path=$META_FILE" >> "$GITHUB_OUTPUT" + fi + + - name: Clean up PFX Certificate + if: always() + shell: powershell + run: | + if (Test-Path "cert.pfx") { + Remove-Item -Force "cert.pfx" -ErrorAction SilentlyContinue + }