移动端 CI/CD:Flutter/iOS/Android 构建与签名

深度解析移动端在 GitHub Actions 上的完整 CI/CD 流水线,涵盖 Flutter 测试构建与缓存、iOS 证书与描述文件管理(fastlane match)、Android 签名(keystore 加密)、设备矩阵(emulator/real device)、代码签名安全,以及 App Store/Play 的自动发布流程。

移动端 CI/CD 与 Web 的最大差异在于:代码签名与设备矩阵。iOS 需要证书与描述文件,Android 需要 keystore,且都需要在 CI 的无状态 Runner 上安全重建;而测试需要在模拟器/真机上运行,才能捕获 Web 测试无法暴露的原生问题。本文以 Flutter 为主线,同时覆盖 iOS/Android 双端,给出从「提交 → 测试 → 签名构建 → 商店发布」的完整流水线。


一、移动端 CI/CD 架构总览

1.1 双端流水线差异

维度iOSAndroidFlutter(双端)
构建平台必须 macOSLinux/macOS/Windows 均可按目标平台
签名材料证书 + Provisioning Profilekeystore(.jks/.keystore)随目标平台
分发渠道App Store Connect / TestFlightGoogle Play / 内测渠道两者
测试设备iOS Simulator / 真机Android Emulator / 真机两者

1.2 推荐的流水线分层

L1 静态检查:analyze + format + lint
L2 单元测试:flutter test / xcodebuild / gradlew test
L3 设备测试:emulator / simulator 上的 widget/instrumentation 测试
L4 签名构建:生成 apk / aab / ipa
L5 商店发布:fastlane deliver / supply 上传与元数据

二、Flutter 测试与构建

2.1 Flutter Action 配置

name: Flutter CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  flutter-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          channel: stable           # 或指定 flutter-version: 3.24.0
          flutter-version: 3.24.0
          cache: true               # 缓存 pub 依赖与 Flutter SDK
      - run: flutter pub get
      - run: flutter analyze        # 静态分析,作为质量门禁
      - run: flutter test --coverage --reporter expanded

2.2 构建多平台产物

jobs:
  build-android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          channel: stable
          cache: true
      - run: flutter pub get
      - run: flutter build apk --release --split-per-abi
      - run: flutter build appbundle --release

  build-ios:
    runs-on: macos-14          # iOS 构建必须 macOS
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          channel: stable
          cache: true
      - run: flutter pub get
      - run: flutter build ipa --release --no-codesign   # 先无签名构建验证

一句话:iOS 构建必须在 macOS runner 上执行,且代码签名材料只在「发布 job」中注入——CI 的常规验证构建保持 --no-codesign,避免每次 PR 都触碰私钥。


三、iOS 证书与描述文件管理

3.1 为什么需要 fastlane match

iOS 的代码签名需要 Apple 证书(Development/Distribution)与描述文件(Provisioning Profile)。在 CI 中手动上传这些 .p12 文件既不安全也不可复现。fastlane match 将证书加密存储在私有仓库或云存储中,按需解密注入——这是 iOS CI 的事实标准。

3.2 match 仓库结构

certs/
├── certs/
│   └── distribution/
│       └── XXXXXXXXXX.p12        # 加密的私钥
└── profiles/
    ├── appstore/
    │   └── AppStore_com.example.app.mobileprovision
    └── development/
        └── Development_com.example.app.mobileprovision

3.3 在 CI 中配置 match

jobs:
  build-ios:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          channel: stable
          cache: true

      # 下载加密证书仓库(用独立只读 token)
      - run: |
          git clone --depth 1 \
            https://x-access-token:${{ secrets.MATCH_REPO_TOKEN }}@github.com/org/mobile-certs.git \
            ~/certs

      - name: Install match certificates
        run: |
          bundle install
          bundle exec fastlane match appstore \
            --git_url "https://github.com/org/mobile-certs.git" \
            --app_identifier "com.example.app" \
            --readonly true

      - run: flutter build ipa --release

3.4 match 的密钥与安全参数

参数说明安全要求
MATCH_PASSWORD证书加密口令存 Secrets,绝不入库
MATCH_REPO_TOKEN访问证书仓库的 token独立最小权限,只读
--readonly只拉取不修改CI 中避免并发写冲突
--type appstore证书类型distribution 用于发布

一句话:match 的核心价值是「证书不进代码库」——证书仓库用独立只读 token 拉取,加密口令放 Secrets,CI 每次按需解密重建签名环境。


四、Android 签名与 keystore 加密

4.1 keystore 的安全注入

Android 签名材料是 keystore 文件(.jks)+ 两个口令。在 CI 中最安全的做法是:keystore 以 Base64 存为 Secrets,构建时解码写入,用完即丢。

jobs:
  sign-android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          channel: stable
          cache: true

      # 从 Secrets 解码 keystore(不进代码库)
      - name: Decode keystore
        run: |
          echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks
          echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" >> android/key.properties
          echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
          echo "keyAlias=upload" >> android/key.properties
          echo "storeFile=upload-keystore.jks" >> android/key.properties

      - name: Build signed APK
        run: flutter build apk --release

4.2 build.gradle 签名配置

// android/app/build.gradle.kts
import java.util.Properties

android {
    signingConfigs {
        create("release") {
            val props = Properties().apply {
                val f = rootProject.file("key.properties")
                if (f.exists()) f.inputStream().use { load(it) }
            }
            if (props["storeFile"] != null) {
                storeFile = rootProject.file(props["storeFile"] as String)
                storePassword = props["storePassword"] as String
                keyAlias = props["keyAlias"] as String
                keyPassword = props["keyPassword"] as String
            }
        }
    }
    buildTypes {
        getByName("release") {
            signingConfig = signingConfigs.getByName("release")
        }
    }
}

4.3 签名安全对比

方案存储位置泄露风险推荐度
keystore 明文入库代码仓库极高❌
Base64 Secrets 解码仓库级 Secrets低(需 actions 权限)✅
OIDC + 密钥托管服务云 KMS / Vault极低✅✅ 大型团队

一句话:keystore 永远以 Base64 形式存在于 Secrets 中,构建时解码、构建后丢弃——key.properties 与 .jks 文件必须写进 .gitignore。


五、设备矩阵:模拟器与真机

5.1 Android Emulator 测试

reactivecircus/android-emulator-runner 是 Android 设备测试的事实标准:

jobs:
  android-instrumented-tests:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        api-level: [29, 34]           # 多 Android 版本矩阵
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 17
      - uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: ${{ matrix.api-level }}
          arch: x86_64
          profile: pixel_5
          script: ./gradlew connectedDebugAndroidTest

5.2 iOS Simulator 测试

iOS 模拟器测试在 macOS runner 上使用 xcodebuild 与 xcrun simctl:

jobs:
  ios-simulator-tests:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - name: Select Xcode version
        run: sudo xcode-select -s /Applications/Xcode_15.4.app

      - name: Run tests on simulator
        run: |
          xcodebuild test \
            -workspace ios/Runner.xcworkspace \
            -scheme Runner \
            -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.5'

5.3 设备矩阵策略

层级方式覆盖率成本适用
单元测试JVM/原生高(逻辑)低每次 commit
模拟器测试Emulator/Simulator中(UI)中PR / nightly
真机云测试Firebase Test Lab / BrowserStack高(真实硬件)高发布前 / 回归

一句话:CI 中用模拟器矩阵覆盖「每次提交」,用云端真机测试覆盖「发布候选」——性价比与风险平衡的黄金组合。


六、App Store / Play 发布

6.1 用 fastlane 统一发布

jobs:
  publish-ios:
    needs: [build-ios, tests]
    runs-on: macos-14
    if: github.ref == 'refs/heads/main'
    environment:
      name: production
    steps:
      - uses: actions/checkout@v4
      - run: |
          bundle install
          bundle exec fastlane match appstore --readonly
      - run: flutter build ipa --release
      - name: Upload to App Store Connect
        run: |
          bundle exec fastlane deliver \
            --api_key_path ~/fastlane/AppStoreConnect.json \
            --ipa "./build/ios/ipa/Runner.ipa"

  publish-android:
    needs: [build-android, tests]
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment:
      name: production
    steps:
      - uses: actions/checkout@v4
      - run: flutter build appbundle --release
      - name: Upload to Google Play
        run: |
          bundle exec fastlane supply \
            --json_key ~/fastlane/play-service-account.json \
            --aab ./build/app/outputs/bundle/release/app-release.aab \
            --track production

6.2 App Store Connect 与 Play 的凭证安全

渠道凭证存储建议
App StoreAppStoreConnect.json(API Key)环境级 Secrets Base64 解码
Play 商店服务账号 .json环境级 Secrets Base64 解码
证书口令MATCH_PASSWORDSecrets,独立最小权限

6.3 发布门禁

发布 job 应挂在 production environment 下,叠加审批与分支限制:

jobs:
  publish:
    runs-on: macos-14
    environment:
      name: production
      url: https://app.example.com
    steps:
      - run: ./upload.sh

七、代码签名安全加固

7.1 签名材料的威胁模型

攻击面风险防护
证书仓库泄露私钥被窃取独立只读 token + match 加密口令
CI 日志回显口令被打印一律 env: 注入,禁止 ${{ secrets }} 拼入命令
共享 Runner 残留keystore 残留磁盘构建后立即删除敏感文件
fork PR 触发恶意代码窃取证书发布 job 绝不响应 fork PR 事件

7.2 安全构建步骤

- name: Sign and build
  env:
    KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
    KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
  run: |
    flutter build apk --release
    # 构建完成立即清理敏感文件
    rm -f android/app/upload-keystore.jks android/key.properties

7.3 fork PR 与签名隔离

# 只允许受信来源触发签名构建
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
    types: [opened, synchronize]

jobs:
  # 普通 PR 只跑无签名测试
  test:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - run: flutter test

  # 发布仅在 main 分支执行
  publish:
    if: github.ref == 'refs/heads/main'
    environment: production
    steps:
      - run: ./sign-and-upload.sh

一句话:签名材料只在受信分支的发布 job 中出现——fork PR 永远只能跑无签名测试,这是移动端 CI 的安全底线。


八、移动端 CI 最佳实践清单

  • Flutter Action 开启 cache: true,避免每次重下 SDK
  • 常规验证构建使用 --no-codesign,签名只在发布阶段
  • match 证书仓库用独立只读 token,口令放 Secrets
  • keystore 以 Base64 存 Secrets,构建后立即删除
  • key.properties、*.jks、*.p12 全部加入 .gitignore
  • 模拟器矩阵覆盖多次提交,真机云测试覆盖发布候选
  • 发布 job 挂 production 环境 + 审批 + 分支限制
  • fork PR 永远无权访问签名材料

总结

移动端 CI/CD 的复杂度集中体现在「签名」与「设备」两个维度。

环节核心工具关键决策
Flutter 构建subosito/flutter-action固定版本、开启缓存
iOS 签名fastlane match证书仓库隔离 + 只读 token
Android 签名keystore Base64 Secrets用完即删、不进仓库
设备测试emulator-runner / xcodebuild模拟器高频 + 真机低频
商店发布fastlane deliver / supply环境门禁 + 受信分支
安全底线签名材料与 fork 隔离只在发布 job 注入证书

一句话:用「常规 job 无签名验证 + 发布 job 注入签名」把安全与效率分开,用「模拟器矩阵 + 真机云测」把覆盖与成本分开——移动端 CI/CD 的工程化即是对这两组平衡点的持续打磨。


延伸阅读:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「github-actions」更多文章

  1. 环境保护与部署门禁:环境规则、审批与 CD 流程
  2. 工作流安全加固与供应链防御
  3. 基础设施即代码自动化:Terraform/CloudFormation 与 Atlantis