Release #1
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| # Tag 触发自动发布:iOS 上传 App Store Connect、Android 上传 Cloudflare R2、 | |
| # 同时把 APK 作为 GitHub Release 附件挂出(IPA 不公开)。 | |
| # | |
| # 触发: | |
| # - push tag 形如 v1.0.8+1 | |
| # - 或在 Actions 页面手动 dispatch 选 tag | |
| on: | |
| push: | |
| tags: ['v*'] | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: 'Tag to release (e.g. v1.0.8+1)' | |
| required: true | |
| env: | |
| FLUTTER_VERSION: '3.41.5' | |
| jobs: | |
| release: | |
| timeout-minutes: 60 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| platform: [ios, android] | |
| include: | |
| - platform: ios | |
| runner: macos-latest | |
| - platform: android | |
| runner: ubuntu-latest | |
| runs-on: ${{ matrix.runner }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ github.event.inputs.tag || github.ref }} | |
| - name: Setup Flutter | |
| uses: subosito/flutter-action@v2 | |
| with: | |
| flutter-version: ${{ env.FLUTTER_VERSION }} | |
| cache: true | |
| cache-key: flutter-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} | |
| - name: Select latest Xcode (iOS only) | |
| if: matrix.platform == 'ios' | |
| uses: maxim-lobanov/setup-xcode@v1 | |
| with: | |
| xcode-version: latest-stable | |
| - name: Get dependencies | |
| run: flutter pub get | |
| - name: Parse version from tag | |
| id: ver | |
| env: | |
| TAG_REF: ${{ github.event.inputs.tag || github.ref_name }} | |
| run: | | |
| NAME="${TAG_REF#v}" | |
| NAME="${NAME%+*}" | |
| NUMBER="${TAG_REF##*+}" | |
| if [[ "$NAME" == "$TAG_REF" || "$NUMBER" == "$TAG_REF" || -z "$NAME" || -z "$NUMBER" ]]; then | |
| echo "ERROR: tag '$TAG_REF' is not in vX.Y.Z+N format" >&2 | |
| exit 1 | |
| fi | |
| PUBSPEC_VERSION="$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f1)" | |
| if [[ "$NAME" != "$PUBSPEC_VERSION" ]]; then | |
| echo "ERROR: tag version ($NAME) does not match pubspec.yaml ($PUBSPEC_VERSION)" >&2 | |
| echo "请先升级 pubspec.yaml 的 version 字段,再打 tag。" >&2 | |
| exit 1 | |
| fi | |
| echo "name=$NAME" >> "$GITHUB_OUTPUT" | |
| echo "number=$NUMBER" >> "$GITHUB_OUTPUT" | |
| echo "version=${NAME}+${NUMBER}" >> "$GITHUB_OUTPUT" | |
| echo "Parsed: name=$NAME number=$NUMBER (pubspec OK)" | |
| # ---------- Android ---------- | |
| - name: Setup Java | |
| if: matrix.platform == 'android' | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: '17' | |
| - name: Restore Android signing config | |
| if: matrix.platform == 'android' | |
| env: | |
| ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} | |
| ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} | |
| ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} | |
| ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} | |
| run: | | |
| echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks | |
| cat > android/key.properties <<EOF | |
| storePassword=$ANDROID_KEYSTORE_PASSWORD | |
| keyPassword=$ANDROID_KEY_PASSWORD | |
| keyAlias=$ANDROID_KEY_ALIAS | |
| storeFile=../app/upload-keystore.jks | |
| EOF | |
| - name: Build Android APK | |
| if: matrix.platform == 'android' | |
| env: | |
| BUILD_NAME: ${{ steps.ver.outputs.name }} | |
| BUILD_NUMBER: ${{ steps.ver.outputs.number }} | |
| BUILD_VERSION: ${{ steps.ver.outputs.version }} | |
| API_BASE_URL: ${{ vars.API_BASE_URL }} | |
| POSTHOG_HOST: ${{ vars.POSTHOG_HOST }} | |
| POSTHOG_API_KEY: ${{ vars.POSTHOG_API_KEY }} | |
| run: | | |
| flutter clean | |
| DART_DEFINES=( | |
| "--dart-define=API_BASE_URL=${API_BASE_URL}" | |
| "--dart-define=POSTHOG_HOST=${POSTHOG_HOST}" | |
| ) | |
| if [[ -n "$POSTHOG_API_KEY" ]]; then | |
| DART_DEFINES+=("--dart-define=POSTHOG_API_KEY=${POSTHOG_API_KEY}") | |
| fi | |
| flutter build apk --release --flavor=prod \ | |
| --target-platform android-arm64 \ | |
| --build-name="$BUILD_NAME" \ | |
| --build-number="$BUILD_NUMBER" \ | |
| "${DART_DEFINES[@]}" | |
| mkdir -p build/release | |
| cp build/app/outputs/flutter-apk/app-prod-release.apk \ | |
| "build/release/Echo-Loop-${BUILD_VERSION}-arm64.apk" | |
| - name: Upload APK to Cloudflare R2 | |
| if: matrix.platform == 'android' | |
| env: | |
| BUILD_VERSION: ${{ steps.ver.outputs.version }} | |
| AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} | |
| R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} | |
| R2_BUCKET: ${{ vars.R2_BUCKET }} | |
| run: | | |
| : "${R2_BUCKET:=public}" | |
| APK="build/release/Echo-Loop-${BUILD_VERSION}-arm64.apk" | |
| aws s3 cp "$APK" "s3://${R2_BUCKET}/android/$(basename "$APK")" \ | |
| --endpoint-url "$R2_ENDPOINT" --region auto \ | |
| --content-type "application/vnd.android.package-archive" | |
| aws s3 cp "$APK" "s3://${R2_BUCKET}/android/Echo-Loop-latest.apk" \ | |
| --endpoint-url "$R2_ENDPOINT" --region auto \ | |
| --content-type "application/vnd.android.package-archive" | |
| - name: Upload APK artifact | |
| if: matrix.platform == 'android' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: android-apk | |
| path: build/release/Echo-Loop-*-arm64.apk | |
| retention-days: 30 | |
| # ---------- iOS ---------- | |
| - name: Import distribution certificate to keychain | |
| if: matrix.platform == 'ios' | |
| env: | |
| IOS_DIST_CERT_BASE64: ${{ secrets.IOS_DIST_CERT_BASE64 }} | |
| IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }} | |
| run: | | |
| KEYCHAIN_PATH="$RUNNER_TEMP/signing.keychain-db" | |
| KEYCHAIN_PWD="$(openssl rand -base64 24)" | |
| CERT_PATH="$RUNNER_TEMP/dist_cert.p12" | |
| echo "$IOS_DIST_CERT_BASE64" | base64 -d > "$CERT_PATH" | |
| security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" | |
| security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" | |
| security import "$CERT_PATH" -P "$IOS_DIST_CERT_PASSWORD" \ | |
| -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" | |
| security set-key-partition-list -S apple-tool:,apple: \ | |
| -k "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" | |
| security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain | |
| echo "Available signing identities:" | |
| security find-identity -v -p codesigning "$KEYCHAIN_PATH" | |
| - name: Install provisioning profile | |
| if: matrix.platform == 'ios' | |
| env: | |
| IOS_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }} | |
| run: | | |
| PROFILES_DIR="$HOME/Library/MobileDevice/Provisioning Profiles" | |
| mkdir -p "$PROFILES_DIR" | |
| echo "$IOS_PROVISIONING_PROFILE_BASE64" | base64 -d \ | |
| > "$PROFILES_DIR/profile.mobileprovision" | |
| ls -la "$PROFILES_DIR" | |
| - name: Install App Store Connect API Key | |
| if: matrix.platform == 'ios' | |
| env: | |
| APP_STORE_API_KEY_BASE64: ${{ secrets.APP_STORE_API_KEY_BASE64 }} | |
| APP_STORE_API_KEY_ID: ${{ vars.APP_STORE_API_KEY_ID }} | |
| run: | | |
| KEYS_DIR="$HOME/.appstoreconnect/private_keys" | |
| mkdir -p "$KEYS_DIR" | |
| echo "$APP_STORE_API_KEY_BASE64" | base64 -d \ | |
| > "$KEYS_DIR/AuthKey_${APP_STORE_API_KEY_ID}.p8" | |
| - name: CocoaPods install | |
| if: matrix.platform == 'ios' | |
| run: cd ios && pod install | |
| - name: Generate ExportOptions.plist | |
| if: matrix.platform == 'ios' | |
| env: | |
| IOS_TEAM_ID: ${{ vars.IOS_TEAM_ID }} | |
| IOS_PROVISIONING_PROFILE_NAME: ${{ vars.IOS_PROVISIONING_PROFILE_NAME }} | |
| run: | | |
| cat > "$RUNNER_TEMP/ExportOptions.plist" <<EOF | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <plist version="1.0"> | |
| <dict> | |
| <key>destination</key> | |
| <string>export</string> | |
| <key>manageAppVersionAndBuildNumber</key> | |
| <false/> | |
| <key>method</key> | |
| <string>app-store-connect</string> | |
| <key>signingStyle</key> | |
| <string>manual</string> | |
| <key>provisioningProfiles</key> | |
| <dict> | |
| <key>top.echo-loop</key> | |
| <string>${IOS_PROVISIONING_PROFILE_NAME}</string> | |
| </dict> | |
| <key>stripSwiftSymbols</key> | |
| <true/> | |
| <key>teamID</key> | |
| <string>${IOS_TEAM_ID}</string> | |
| <key>uploadSymbols</key> | |
| <true/> | |
| </dict> | |
| </plist> | |
| EOF | |
| plutil -lint "$RUNNER_TEMP/ExportOptions.plist" | |
| - name: Build iOS app (no codesign) | |
| if: matrix.platform == 'ios' | |
| env: | |
| BUILD_NAME: ${{ steps.ver.outputs.name }} | |
| BUILD_NUMBER: ${{ steps.ver.outputs.number }} | |
| API_BASE_URL: ${{ vars.API_BASE_URL }} | |
| POSTHOG_HOST: ${{ vars.POSTHOG_HOST }} | |
| POSTHOG_API_KEY: ${{ vars.POSTHOG_API_KEY }} | |
| run: | | |
| # 避免 Homebrew rsync 抢占(Apple toolchain 需要系统 rsync) | |
| export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH" | |
| DART_DEFINES=( | |
| "--dart-define=API_BASE_URL=${API_BASE_URL}" | |
| "--dart-define=POSTHOG_HOST=${POSTHOG_HOST}" | |
| ) | |
| if [[ -n "$POSTHOG_API_KEY" ]]; then | |
| DART_DEFINES+=("--dart-define=POSTHOG_API_KEY=${POSTHOG_API_KEY}") | |
| fi | |
| # 先用 flutter 编译 dart 代码,--no-codesign 跳过签名 | |
| flutter build ios --release --no-codesign --flavor=prod \ | |
| --build-name="$BUILD_NAME" \ | |
| --build-number="$BUILD_NUMBER" \ | |
| "${DART_DEFINES[@]}" | |
| - name: Patch Runner pbxproj for manual signing | |
| if: matrix.platform == 'ios' | |
| env: | |
| IOS_TEAM_ID: ${{ vars.IOS_TEAM_ID }} | |
| IOS_PROVISIONING_PROFILE_NAME: ${{ vars.IOS_PROVISIONING_PROFILE_NAME }} | |
| run: | | |
| # 用 xcodeproj gem 精确修改 Runner target 的 Release-prod 配置。 | |
| # 不动 Pods.xcodeproj 和其他 target/config,避免污染。 | |
| # CocoaPods 依赖 xcodeproj gem,macos-latest 自带 ruby + cocoapods,gem 已就绪。 | |
| gem list xcodeproj | grep -q xcodeproj || sudo gem install xcodeproj | |
| ruby <<'RBEOF' | |
| require 'xcodeproj' | |
| project = Xcodeproj::Project.open('ios/Runner.xcodeproj') | |
| profile = ENV.fetch('IOS_PROVISIONING_PROFILE_NAME') | |
| team = ENV.fetch('IOS_TEAM_ID') | |
| patched = 0 | |
| project.targets.each do |target| | |
| next unless target.name == 'Runner' | |
| target.build_configurations.each do |config| | |
| next unless config.name == 'Release-prod' | |
| config.build_settings['CODE_SIGN_STYLE'] = 'Manual' | |
| config.build_settings['DEVELOPMENT_TEAM'] = team | |
| config.build_settings['CODE_SIGN_IDENTITY'] = 'Apple Distribution' | |
| config.build_settings['CODE_SIGN_IDENTITY[sdk=iphoneos*]'] = 'Apple Distribution' | |
| config.build_settings['PROVISIONING_PROFILE_SPECIFIER'] = profile | |
| puts "Patched #{target.name}/#{config.name}" | |
| patched += 1 | |
| end | |
| end | |
| project.save | |
| raise "Did not find Runner/Release-prod" if patched == 0 | |
| RBEOF | |
| - name: Archive iOS app | |
| if: matrix.platform == 'ios' | |
| run: | | |
| export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH" | |
| # 签名配置已写进 Runner pbxproj,命令行不传签名 args 避免污染 Pods | |
| xcodebuild \ | |
| -workspace ios/Runner.xcworkspace \ | |
| -scheme prod \ | |
| -configuration Release-prod \ | |
| -destination 'generic/platform=iOS' \ | |
| -archivePath "$RUNNER_TEMP/Runner.xcarchive" \ | |
| archive | |
| - name: Export IPA from archive | |
| if: matrix.platform == 'ios' | |
| run: | | |
| export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH" | |
| mkdir -p build/ios/ipa | |
| xcodebuild -exportArchive \ | |
| -archivePath "$RUNNER_TEMP/Runner.xcarchive" \ | |
| -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" \ | |
| -exportPath "$(pwd)/build/ios/ipa" | |
| - name: Upload IPA to App Store Connect | |
| if: matrix.platform == 'ios' | |
| env: | |
| APP_STORE_API_KEY_ID: ${{ vars.APP_STORE_API_KEY_ID }} | |
| APP_STORE_API_ISSUER_ID: ${{ vars.APP_STORE_API_ISSUER_ID }} | |
| run: | | |
| IPA="$(find build/ios/ipa -maxdepth 1 -name '*.ipa' | head -n 1)" | |
| [[ -n "$IPA" ]] || { echo "No IPA produced" >&2; exit 1; } | |
| echo "Uploading: $IPA" | |
| xcrun altool --upload-app --type ios --file "$IPA" \ | |
| --apiKey "$APP_STORE_API_KEY_ID" \ | |
| --apiIssuer "$APP_STORE_API_ISSUER_ID" \ | |
| --p8-file-path "$HOME/.appstoreconnect/private_keys/AuthKey_${APP_STORE_API_KEY_ID}.p8" \ | |
| --show-progress | |
| publish: | |
| needs: release | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Download APK artifact | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: android-apk | |
| path: ./artifacts | |
| - name: Create GitHub Release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ github.event.inputs.tag || github.ref_name }} | |
| files: ./artifacts/Echo-Loop-*-arm64.apk | |
| generate_release_notes: true | |
| fail_on_unmatched_files: true |