← 返回蜂巢洞察

如何使用 Fastlane 和 GitHub Actions 自动化Flutter应用的发布流程,以便将其分发到 Firebase App Distribution、Google Play、TestFlight 以及 App Store Connect平台上

想象一下:现在是周五下午4点,你的团队刚刚完成了本次迭代周期的最后一项功能开发。但产品经理要求在当天结束前通过TestFlight发送一个新的版本,这样客户就可以在周末进行测试。 你打开Xcode,等待代码打包完成,处理一个昨天还不存在的签名错误,修复问题后重新打包代码,再次等待上传,然后等待App Store Connect进行处理。接着你需要为Android版本重复这些步骤,不过这次是通过Android Studio来操作的。你需要签署APK文件,登录Firebase App Distribution平台,将文件上传进去,添加测试人员,编写发布说明,最后点击发送按钮。 现在已经是下午6点4

想象一下:现在是周五下午4点,你的团队刚刚完成了本次迭代周期的最后一项功能开发。但产品经理要求在当天结束前通过TestFlight发送一个新的版本,这样客户就可以在周末进行测试。

你打开Xcode,等待代码打包完成,处理一个昨天还不存在的签名错误,修复问题后重新打包代码,再次等待上传,然后等待App Store Connect进行处理。接着你需要为Android版本重复这些步骤,不过这次是通过Android Studio来操作的。你需要签署APK文件,登录Firebase App Distribution平台,将文件上传进去,添加测试人员,编写发布说明,最后点击发送按钮。

现在已经是下午6点45分了。在过去的两个小时里,你一行产品代码都没有编写过。每个版本发布周期都会遇到这种情况。

现在再想象另一种情况:你将代码推送到dev分支上,GitHub的服务器会自动处理后续工作。几分钟内,一个独立的云环境就会下载你的代码,安装Flutter框架,解密加密过的签名凭证,生成APK和IPA文件,然后同时将这些文件发送到Firebase App Distribution平台供Android测试人员使用,以及通过TestFlight发送给iOS测试人员。这样你就可以回家休息了,系统会自动通知所有测试人员。

这本手册所要介绍的正是这样的工作流程。

当你读完这份指南时,你会发现将代码推送到dev分支后,构建好的版本会自动被分发到Firebase App Distribution平台和TestFlight上。而将代码推送到prod分支,则会同时发布到Google Play商店和Apple App Store上。从此以后,你再也不用手动导出IPA文件或上传APK文件了。

实现这一切的工具是GitHub Actions,它提供了用于执行自动化操作的云服务器;而Fastlane则负责处理代码构建、签名以及分发等相关工作。这本手册将这两项工具都视为重要的生产基础设施,认为它们和应用程序本身一样,需要得到同样的重视和详细的文档说明。

目录

先决条件

在开始之前,请确保以下条件都已满足。如果缺少任何一项,都会导致难以诊断的故障。

  1. 拥有GitHub仓库的Flutter项目:该项目应该已经能够在本地进行构建了。如果在你的机器上,flutter build apk --releaseflutter build ios --release --no-codesign这两个命令都能成功执行,那么你就准备好了。

  2. 拥有管理员或账户持有者权限的Apple开发者账户:你需要这个账户来创建App Store Connect API密钥。仅具有开发者权限是不够的。

  3. 拥有至少处于“草稿”状态的已发布应用的Google Play Console账户:Google Play API无法将应用推送到从未上传过任何版本的应用上。如果你的应用是全新的,你需要先手动上传一次,才能让自动化流程开始运行。

  4. 一个启用了Android和iOS版本发布的Firebase项目:

  5. 在开发机器上安装了Ruby:

    Fastlane是一个基于Ruby开发的工具。运行ruby -v即可检查是否已安装Ruby。macOS系统本身预装了Ruby,但版本可能比较陈旧。可以通过Homebrew安装最新版本的Ruby:brew install ruby

  6. 在本地安装了Fastlane:

    使用gem install fastlane命令进行安装。在配置过程中,你需要在终端中使用这个工具,之后CI服务器会接管相关任务。

  7. 在macOS上安装了Homebrew:

    Homebrew用于在本地安装各种开发依赖项。

  8. 熟悉使用的终端环境:

    本指南中的每一步操作都需要执行命令。对于大多数步骤来说,都没有图形化界面可供使用。

什么是CI/CD?为什么你的Flutter应用需要它

概念说明

CI/CD代表持续集成和持续交付。其核心理念就是自动化代码编写与代码发布之间的整个流程。持续集成意味着每次代码修改都会自动被构建并测试;而持续交付则意味着每次成功的构建结果都会自动被准备好进行分发。

对于移动应用开发来说,这一概念的重要性远超其他软件领域。移动应用的构建过程非常复杂:涉及到使用证书对代码进行签名、配置各种配置文件、管理密钥库以及生成API密钥等等,这些步骤必须以正确的方式完成,才能确保构建成功。如果这些操作都是手动完成的,出错的风险就会大大增加;而通过自动化手段来执行这些任务,就可以确保过程的可靠性和可重复性。

为什么手动部署存在问题

当部署过程是手动的时,随着时间的推移,会出现一些问题。首先,这会使得部署成为一项需要专门技能的任务——只有那些曾经做过这项工作的人才了解具体的操作步骤;而当这些人无法参与开发工作时,团队就无法完成应用的发布工作。

其次,这种构建方式存在不一致性。某个人在笔记本电脑上进行的构建过程所使用的环境变量或Xcode设置,可能与另一个人使用相同设备进行构建时所设置的参数有所不同。

第三,这样的构建流程效率很低。构建、生成归档文件以及上传操作都会中断实际的工程开发工作流程。

自动化技术能够解决这三个问题。所有构建步骤都被记录在版本控制系统中,因此每次运行时环境都是相同的——因为这些步骤都是通过这些版本控制文件在云端重新创建的虚拟机器来执行的。而且,在你继续进行其他开发工作的同时,这些构建过程可以在后台自动运行。

一张名为“手动部署与自动化部署对比”的图表。左侧展示了开发人员在自己的电脑上执行手动移动应用部署的过程,包括打开Xcode或Android Studio、生成应用程序归档文件并构建应用、解决签名问题、重新构建应用、上传应用、等待处理结果、编写发布说明以及通知测试人员等步骤。该图表指出,这种手动部署方式通常每次需要1到3小时的时间,并且容易出现人为错误、环境设置不一致以及信息孤岛等问题。右侧则展示了自动化部署流程:开发人员将代码推送到指定的分支,GitHub Actions会自动在云端虚拟机器上执行相应的构建任务,包括检查代码、安装Flutter框架、解密加密密钥、构建并签署Android和iOS应用程序、将生成的应用程序文件上传到Firebase App Distribution和TestFlight平台,同时也会自动通知测试人员。该图表强调,使用自动化部署方式后,开发人员的工作量仅限于推送代码,完全不需要再进行任何手动部署操作,整个流程都是基于版本控制机制进行的,因此能够有效减少人为错误并确保发布的应用程序质量始终如一。

架构:各组成部分之间的关联关系

在修改任何配置文件之前,首先需要了解整个系统的运作原理以及各个组件是如何相互配合的。如果不先弄清楚这些关系,直接开始开发工作的话,遇到问题时就会不知道该从哪里入手进行调试。

GitHub Actions提供了基于云端的虚拟机器来执行构建任务。每当你将代码推送到指定的分支时,GitHub会自动启动一台新的虚拟机器(对于Android应用使用Ubuntu系统,对于iOS应用使用macOS系统),然后执行你在工作流文件中定义的构建步骤,完成后就会关闭这台虚拟机器。每次使用时,这台虚拟机器都会被彻底重置为初始状态。

Fastlane是一款开源工具,专门用于自动化移动应用的构建和部署流程。它会在GitHub Actions提供的虚拟机器环境中运行,负责处理与特定平台相关的操作,例如生成应用程序包文件、管理iOS应用的代码签名过程以及将编译后的二进制文件上传到相应的分发平台。你需要编写一系列名为“lane”的脚本序列,GitHub Actions会按照这些脚本来自动执行相应的构建流程。

Fastlane Match是Fastlane系统中专门用于iOS代码签名的子系统。开发iOS应用程序时,需要在用于构建这些应用的机器上安装证书和配置文件。Match会将这些文件存储在加密后的私有GitHub仓库中,并在构建过程开始之前将它们下载到持续集成工具中。这样一来,就彻底避免了在多台机器上手动管理证书所带来的麻烦。

Firebase应用分发服务会接收您为开发环境准备的APK和IPA文件,并自动通知您的测试人员。

App Store Connect与Google Play Console则会接收您为生产环境准备的应用版本。

一张流程图,展示了Flutter应用的整体CI/CD架构。最上方是一个GitHub仓库,其中包含四个分支:受保护的“main”和“develop”分支仅可供查看,而“dev”和“prod”分支则会触发Android和iOS的开发流程。流程继续向下延伸至GitHub Actions,那里有两个并行运行的任务执行器:一个用于Android的Ubuntu运行器,另一个用于iOS的macOS运行器。Android运行器会拉取代码、安装Flutter工具、解密Android密钥库、生成APK文件,并通过Fastlane来分发开发版本或生产版本的应用;iOS运行器也会执行相同的操作,但会处理Apple相关的认证信息并生成iOS应用包,同样通过Fastlane进行分发。开发版本的应用会被上传至Firebase应用分发服务,而iOS版本还会被发送到TestFlight平台进行测试;生产版本的Android应用会被上传至Google Play Console,iOS版本则会被上传至App Store Connect以等待审核和发布。

证书存储库是一个独立的私有GitHub仓库,Fastlane Match会从这个仓库中读取和写入数据。其中保存了您的iOS应用签名所需的文件,这些文件都经过了加密处理,而解密密码只有您自己知道。

一张图示,说明了Fastlane Match是如何管理iOS应用代码签名证书的。最上方是一个私有GitHub仓库,其中存储了被MATCH_PASSWORD保护的加密签名文件。该仓库包含App Store分发证书、Ad-Hoc分发证书、App Store配置文件以及Ad-Hoc配置文件。一条箭头指向下方的Fastlane Match,在macOS上的GitHub Actions运行器进行CI构建时,Fastlane Match会检索并解密这些证书。最后一步显示的是,iOS应用使用这些解密后的证书成功完成了构建过程,开发者无需手动管理证书即可完成开发工作。

生成您的凭证和密钥

这一环节需要您访问多个第三方管理平台,以收集CI流程所需的各类凭证。

Firebase凭证

Firebase应用分发服务需要两样信息:您的应用程序ID,以及一个服务账户——该账户需赋予CI服务器上传应用版本的权限。

登录Firebase控制台并打开您的项目。

Firebase控制台项目概览

进入项目设置(左侧导航栏中“项目概览”旁边的齿轮图标)。

Firebase控制台左侧导航栏,齿轮图标已被高亮显示,同时“项目设置”页面已打开

向下滚动至您的应用部分。您会看到自己注册的Android和iOS应用列表。找到并复制每项应用的应用ID。Android应用ID的形式为1:1234567890:android:abc123def456,iOS应用ID的形式也为1:1234567890:ios:abc123def456

Firebase控制台项目设置页面,“您的应用”部分中同时显示Android和iOS应用的详细信息,应用ID字段已被高亮显示" height="400" loading="lazy" src="https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/22fdc2fd-7173-4d82-a0a6-9e1f22be13a8.png" style="display:block;margin:0 auto" width="600"/>
<p>继续留在项目设置页面,然后点击<strong>服务账户</strong>选项卡。</p>
<img alt=

点击生成新的私钥,然后确认弹出的对话框。系统会将一个.json文件下载到您的设备上。这个文件就是服务账户的凭证,请妥善保管,切勿将其上传到任何代码仓库中。

生成密钥时出现的确认对话框

Apple App Store Connect API密钥

苹果已经将基于密码的API访问方式替换为API密钥。您需要这个密钥,才能让Fastlane在不需要使用您的Apple ID凭证的情况下与App Store Connect进行通信。

访问App Store Connect网站,然后在顶部导航栏中选择用户与访问权限选项。

App Store Connect首页,顶部导航栏中显示“用户与访问权限”选项" height="400" loading="lazy" src="https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/368acdea-0f46-4ff1-9eee-42808a4903c6.png" style="display:block;margin:0 auto" width="600"/>
<p>点击<strong>集成</strong>选项卡,然后在左侧导航栏中选择<strong>App Store Connect API</strong>。<img alt="App Store Connect用户界面,其中‘集成’选项卡已被选中,侧边栏中显示了App Store Connect API相关内容。图像高度为400像素,加载方式为‘延迟加载’,图片来源为https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/cd535eea-8343-4514-9b60-f1cec6652153.png,显示方式为‘块级布局,边距为0,自动居中’,宽度也为600像素。" style="display:block;margin:0 auto width=600">
<p>点击<strong>+</strong>按钮来生成一个新的密钥。给这个密钥起一个清晰易懂的名字,比如<code>GitHub Actions CI</code>。将访问权限设置为<strong>应用管理员</strong>。</p>
<img alt="App Store Connect API密钥创建界面,名称和访问权限相关字段均可见。图像高度为400像素,加载方式为‘延迟加载’,图片来源为https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/44b0f66a-d000-42e5-9e42-6ff002e3375a.png,显示方式为‘块级布局,边距为0,自动居中’,宽度也为600像素。" style="display:block;margin:0 auto width=600">
<p>创建密钥后,请记下页面顶部显示的<strong>发行者ID</strong>,以及密钥信息行中显示的<strong>密钥ID</strong>。点击<strong>下载API密钥</strong>来保存<code>.p8</code>文件。这个文件只能下载一次,如果丢失了,就必须重新创建。</p>
<img alt="App Store Connect API密钥列表,页面顶部显示发行者ID,密钥信息行中包含密钥ID列和下载按钮。图像高度为400像素,加载方式为‘延迟加载’,图片来源为https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/0320fd6a-76fb-4e67-b028-dc6c554c352b.png,显示方式为‘块级布局,边距为0,自动居中’,宽度也为600像素。" style="display:block;margin:0 auto width=600">
<h3 id="heading-google-play-store-service-account">Google Play Store服务账户</h3>
<p>Google Play API使用服务账户(即Google Cloud中的机器身份)来进行上传操作的认证。</p>
<p>打开<a href="https://console.cloud.google.com">Google Cloud Console</a>,确保你当前处于与你的Play Console关联的项目中。</p>
<img alt="Google Cloud Console项目选择界面,正确的项目已被选中。图像高度为400像素,加载方式为‘延迟加载’,图片来源为https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/f7fa6bdb-a1d2-4bce-97ca-677de7eb6484.png,显示方式为‘块级布局,边距为0,自动居中’,宽度也为600像素。" style="display:block;margin:0 auto width=600">
<p>在左侧侧边栏中导航到<strong>IAM与管理员</strong>选项,然后点击<strong>服务账户</strong>。</p>
<img alt="Google Cloud Console界面,侧边栏中展开的IAM与管理员选项卡,服务账户信息可见。图像高度为400像素,加载方式为‘延迟加载’,图片来源为https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/3fdeb9f5-51e9-4810-afbf-90564019f72e.png,显示方式为‘块级布局,边距为0,自动居中’,宽度也为600像素。" style="display:block;margin:0 auto width=600">
<p>点击<strong>创建服务账户</strong>。给这个新账户起一个清晰的名字,比如<code>github-actions-play-store</code>。将其角色设置为<strong>服务账户用户</strong>,然后完成创建流程。</p>
<img alt="Google Cloud Console创建服务账户的界面,名称和角色相关字段均可见。图像高度为400像素,加载方式为‘延迟加载’,图片来源为https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/3217cf5a-0fdc-4ee5-9ba5-1778094bdbca.png,显示方式为‘块级布局,边距为0,自动居中’,宽度也为600像素。" style="display:block;margin:0 auto width=600">
<p>在列表中点击新创建的服务账户,进入<strong>密钥</strong>选项卡。然后依次点击<strong>添加密钥</strong>和<strong>创建新密钥</strong>,选择<strong>JSON</strong>格式,系统会下载一个<code>.json</code>文件。</p><img alt="选中了‘密钥’选项卡,且‘添加密钥’按钮可见的Google Cloud Console服务账户详情页面" height="400" loading="lazy" src="https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/6e8e7b87-45f0-450f-8e0e-74de4c6a094a.png" style="display:block;margin:0 auto" width="600"/>
<p>现在,将这个服务账户关联到您的Play Console。请访问<a href="https://play.google.com/console">Google Play Console</a>,打开您的应用,然后依次进入<strong>设置</strong> > <strong>API访问权限</strong>。至少需要为该服务账户授予<strong>发布管理员</strong>权限,以便其能够操作您的应用。</p>
<img alt="Google Play Console的API访问页面,显示了服务账户列表及权限配置选项" height="400" loading="lazy" src="https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/e5a5e067-648a-45c9-b11e-4fa75fe724ab.png" style="display:block;margin:0 auto" width="600"/>
<h3 id="heading-fastlane-match-certificates-repository">Fastlane匹配证书仓库</h3>
<p>Fastlane Match会将您的iOS签名相关文件存储在一个专用的私有GitHub仓库中。现在请创建一个全新的、完全空的私有仓库,将其命名为<code>your-app-certificates</code>之类的名称。不要向其中添加任何文件。</p>
<img alt="GitHub的新仓库创建页面,已填写仓库名称,选中了‘私有’选项,且所有初始化复选框均未被选中" height="400" loading="lazy" src="https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/003836d4-1912-42d1-a8b6-2237203663ef.png" style="display:block;margin:0 auto" width="600"/>
<p>接下来,需要创建一个个人访问令牌,这样Fastlane才能从CI运行环境中读取和写入这个仓库。请进入您的GitHub账户<strong>设置</strong>,滚动到页面底部,点击<strong>开发者设置</strong>,然后选择<strong>个人访问令牌</strong>,最后点击<strong>经典令牌</strong>。</p>
<img alt="GitHub设置侧边栏,底部显示了‘开发者设置’选项" height="400" loading="lazy" src="https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/43288275-e755-4d00-bebc-5096840b56d4.png" style="display:block;margin:0 auto" width="600"/>
<p>生成一个新的经典令牌。给它起一个具有描述性的名称,比如<code>fastlane-match-ci</code>。在<strong>选择范围</strong>选项中,选中<strong>仓库</strong>权限(这会赋予其对整个仓库的访问权限)。将令牌的有效期设置为至少一年,或者如果您的安全政策允许的话,可以设置为永不过期。生成令牌后,请立即将其复制下来,因为GitHub之后不会再显示这个令牌了。</p>
<img alt="GitHub个人访问令牌创建页面,已选中‘仓库’权限选项,其他相关选项也可见" height="400" loading="lazy" src="https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/82755ac6-eca1-4736-899e-f64c98c92b55.png" style="display:block;margin:0 auto" width="600"/>
<p>新生成的令牌如下:</p>
<img alt="新生成的令牌" height="400" loading="lazy" src="https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/1762b6be-e4cc-42d7-b1ed-94d619c5346f.png" style="display:block;margin:0 auto" width="600"/><h2 id="heading-background-cryptography-turning-files-into-secrets">加密技术基础:将文件转化为秘密信息</h2>
<p>GitHub Secrets仅支持纯文本字符串。而你的签名凭证实际上是二进制文件,例如Android应用的<code>.jks</code>密钥库、Apple应用的<code>.p8</code>密钥文件,以及Firebase服务的<code>.json</code>配置文件。为了将这些二进制文件保存为秘密信息,你需要将它们转换为Base64编码格式——这种格式可以将任何二进制数据表示成由可打印的ASCII字符组成的字符串。</p>
<p>本节中提到的每个命令都将在你的终端中执行。执行完每个命令后,请打开生成的<code>.txt</code>文件,将其全部内容复制出来,并将这个字符串保存在安全的地方(使用密码管理工具会非常方便)。复制完成后,请删除<code>.txt</code>文件。</p>
<h3 id="heading-generating-the-android-keystore">生成Android密钥库</h3>
<p>Android密钥库是你的应用在Play Store上进行身份验证所使用的加密凭证。一旦你使用某个特定的密钥库发布了应用,那么在未来所有的更新中都必须继续使用这个密钥库。如果丢失了这个密钥库,你就无法为现有的应用推送更新了。因此,请务必生成这个密钥库并妥善备份它。</p>
<pre><code class="language-bash">keytool -genkey -v \
  -keystore release-keystore.jks \
  -keyalg RSA \
  -keysize 2048 \
  -validity 10000 \
  -alias YOUR_KEY_ALIAS \
  -dname "CN=你的名字, OU=应用名称, O=你的公司名称, L=你的城市, ST=你的州, C=美国" \
  -storepass "YOUR_secure_PASSWORD" \
  -keypass "YOURSecure_PASSWORD"
</code></pre>
<p><code>keytool</code>是Java开发工具包中的一个组件,也是用于管理Java加密密钥库的标准工具。参数<code>-keystore release-keystore.jks</code>指定了输出文件的名称;<code>-keyalg RSA</code>和<code>-keysize 2048</code>分别指定了加密算法和密钥长度,这些都是Android应用签名时常用的配置选项。</p>
<p><code>-validity 10000</code>将证书的有效期设置为大约27年,这个值也是Play Store推荐使用的标准期限。<code>-alias YOUR_KEY_ALIAS</code>是指你在密钥库中用来识别这条密钥的名称,建议使用与应用名称相关的有意义的名字。参数<code>-dname</code>用于指定证书所有者的身份信息,请用你自己的实际信息进行替换。</p>
<p><code>-storepass</code>和<code>-keypass</code>分别是用于保护密钥库文件及其内部密钥的密码。这两个密码可以设置成相同的值,这样就可以简化GitHub Secrets中的配置流程了。</p>
<p>现在,请将这个密钥库文件转换为Base64编码格式,以便GitHub Secrets能够存储它:</p>
<pre><code class="language-bash">base64 -i release-keystore.jks > release-keystore-base64.txt
</code></pre>
<p>命令<code>base64 -i release-keystore.jks</code>会读取这个二进制格式的<code>.jks</code>文件,并将其转换为Base64字符串。符号<code>></code>用于将输出结果保存到<code>release-keystore-base64.txt</code>文件中,而不会显示在终端上。打开这个文件,复制其中的全部内容(文件内容可能会相当长),然后将其保存到你的密码管理工具中,标签设置为<code>ANDROID_KEYSTORE_BASE64</code>,最后删除<code>.txt</code>文件即可。</p><h3 id="heading-encoding-the-apple-api-key">对Apple API密钥进行编码</h3>
<pre><code class="language-bash">base64 -i AuthKey_YOUR_KEY_ID.p8 > authkey-base64.txt</code></pre>
<p>请将<code>AuthKey_YOUR_KEY_ID.p8</code>替换为从App Store Connect下载的<code>.p8</code>文件的实际文件名。文件名中就包含了密钥ID。该命令会将二进制格式的密钥文件编码成Base64字符串。打开<code>authkey-base64.txt</code>文件,复制其中的内容,将其保存到<code>APPSTORE_API_PRIVATE_KEY_BASE64</code>目录下,然后删除原始的<code>.p8</code>文件。</p>
<h3 id="heading-encoding-github-credentials-for-match">为Fastlane Match配置GitHub凭据并进行编码</h3>
<p>Fastlane Match会使用HTTP Basic Authentication方式与你的证书仓库进行身份验证,而这需要用户名和以Base64格式编码的访问令牌。这种格式是HTTP Basic认证的标准规范。</p>
<pre><code class="language-bash">echo -n "YOUR_GITHUB_USERNAME:YOUR_PERSONAL_ACCESS_TOKEN" | base64</code></pre>
<p><code>echo -n</code>命令会输出不包含换行符的字符串。使用<code>-n</code>选项非常重要:因为如果输出内容中包含换行符,那么在进行Base64编码后,这些换行符也会被保留下来,从而导致凭据信息失效。<code>| base64</code>命令会将输出结果直接传递给Base64编码工具,而不会生成中间文件。编码后的结果会直接显示在终端中。请将其复制并保存到<code>MATCH_GIT_BASIC_AUTHORIZATION</code>目录下。</p>
<h3 id="heading-encoding-your-environment-file">对环境配置文件进行编码</h3>
<p>如果你的Flutter应用程序使用<code>.env</code>文件来存储API密钥等敏感配置信息(这类信息绝对不应该被提交到Git仓库中),那么你需要对这些配置文件进行编码,这样在构建项目之前,持续集成工具才能重新生成这些配置信息:</p>
<pre><code class="language-bash">base64 -i .env > env-base64.txt</code></pre>
<p>系统会从项目根目录读取<code>.env</code>文件,并将其编码成Base64格式。打开<code>env-base64.txt</code>文件,复制其中的内容,将其保存到<code>ENV_FILE_BASE64</code>目录下,然后删除原始的<code>.env</code>文件。如果你的项目并不使用<code>.env</code>文件,就可以跳过这一步骤,并在后续的GitHub Actions工作流配置中删除相应的操作。</p>
<h2 id="heading-configuring-github-actions-secrets">配置GitHub Actions的秘密信息</h2>
<p>一旦所有的凭据信息都完成了编码处理,就需要将它们添加到GitHub仓库的秘密存储系统中。保存在这里的秘密信息在静息状态下是加密的,在工作流日志中也会被隐藏起来(如果这些信息本来会被显示出来,那么在日志中就会显示为<code>***</code>),而且任何在GitHub Actions之外运行的代码都无法访问这些秘密信息。</p>
<p>在GitHub上的你的仓库中,进入顶部导航栏中的<strong>设置</strong>选项。</p>
<img alt=

在左侧侧边栏中,依次点击秘密和变量,然后再点击Actions选项。

GitHub仓库设置页面,左侧侧边栏中“密钥与变量”选项已被展开,同时选中了“操作”选项,从而显示出了密钥管理页面。

对于下面的每一项密钥,请点击“新建仓库密钥”。密钥的名称必须与实际输入的内容完全一致,因为工作流文件会直接引用这些名称。

GitHub Actions密钥页面,显示“新建仓库密钥”按钮以及一个空的密钥列表。

请逐一添加以下密钥:

环境与配置:

  • ENV_FILE_BASE64:对您的.env文件进行编码后得到的Base64字符串。

Firebase与Google Play:

  • FIREBASE_APP_ID_ANDROID:从Firebase控制台复制的Android应用ID(格式为:1:xxx:android:xxx)。

  • FIREBASE_APP_ID_IOS:从Firebase控制台复制的iOS应用ID。

  • FIREBASE_SERVICE_ACCOUNT_JSON:直接粘贴Firebase服务账户.json文件的原始内容。不要对这部分内容进行编码,因为工作流会直接将其写入文件中。

  • GOOGLE_play_json:直接粘贴Google Play服务账户.json文件的原始内容。

Android签名:

  • ANDROID_KEYSTORE_BASE64:对.jks密钥库文件进行编码后得到的Base64字符串。

  • ANDROID_KEY_ALIAS:在生成密钥库时所使用的别名(例如:your-app-key)。

  • ANDROID_KEY_PASSWORD:在生成密钥库时设置的密钥密码。

  • ANDROID_STORE_PASSWORD:在生成密钥库时设置的存储密码。

Apple App Store:

  • APPSTORE_ISSUER_ID:从App Store Connect API密钥页面获取的发行者ID。

  • APPSTORE_API_KEY_ID:从App Store Connect API密钥页面获取的密钥ID。

  • APPSTORE_API_PRIVATE_KEY_BASE64:对.p8文件进行编码后得到的Base64字符串。

Fastlane Match:

  • MATCH_GIT_BASIC_AUTHORIZATION:格式为username:token的Base64字符串。

  • MATCH_PASSWORD:您自己创建的一个强密码。这个密码用于加密Match仓库中的证书。建议使用密码管理工具来生成这样的强密码,并妥善保管它,因为一旦丢失,就必须重新创建证书仓库。

在所有秘密信息都被添加后,GitHub Actions的Secrets页面会显示所有秘密信息的名称列表,而具体值则是隐藏的

为Android项目配置Fastlane

Fastlane for Android会被保存在您的Flutter项目的android/目录中。请创建以下文件。

Gemfile文件

# android/Gemfile

source "https://rubygems.org"
gem "fastlane"

plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)

source "https://rubygems.org"这一行告诉Bundler(Ruby的包管理工具)从哪里获取所需的软件包。gem "fastlane"则表示将Fastlane作为项目的依赖项。

plugins_path这一行用于在Pluginfile文件存在的情况下,从中加载额外的插件配置信息。这种结构使得主Gemfile文件与插件列表可以分开维护,这也是Fastlane项目所遵循的常规做法。

请始终使用Bundler来执行Fastlane命令(即bundle exec fastlane),而不是直接调用fastlane命令。因为Bundler能确保使用Gemfile.lock文件中指定的软件包版本,从而保证在不同机器上构建项目时结果的一致性。

Gradle属性文件

# android/gradle.properties

org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError

org.gradle.jvmargs用于配置Gradle构建过程中Java虚拟机的运行参数。-Xmx4G将最大堆内存大小设置为4GB。-XX:MaxMetaspaceSize=1G将元空间(类元数据)的大小限制为1GB。-XX:ReservedCodeCacheSize=512m为编译后的代码缓存预留512MB的内存。-XX:+HeapDumpOnOutOfMemoryError当JVM内存不足时,会生成堆栈转储文件,从而便于进行故障排查。

如果不进行这样的配置,在使用GitHub Actions进行Gradle构建时,运行过程经常会因为JVM内存限制而失败,因为默认的JVM内存设置超过了标准GitHub托管环境允许的最大值(7GB RAM)。

Android Appfile文件

# android/fastlane/Appfile

json_key_file(ENV["FIREBASE_SERVICE_ACCOUNT_JSON_PATH"])
package_name("com.yourcompany.app")

json_key_file(...)这一行告诉Fastlane从哪里获取用于访问Google Play商店的Service Account JSON文件。该文件的路径由GitHub Actions的工作流步骤来确定,具体值存储在FIREBASE_SERVICE ACCOUNT_JSON_PATH环境变量中。package_name(...)用于指定应用程序的包名。请将com.yourcompany.app替换为您在AndroidManifest.xml文件中定义的实际应用包名。

Android插件文件

# android/fastlane/Pluginfile

gem 'fastlane-plugin-firebase_app_distribution'

这一行代码声明将“Firebase App Distribution”插件作为依赖项添加到项目中。Fastlane的核心安装版本并不包含针对特定平台的插件。通过添加fastlane-plugin-firebase_app_distribution这个Gem包,系统才会具备firebase_appdistribution这个动作,而firebase流程也正是利用这个动作来上传构建文件并通知测试人员。如果没有这一行代码,当firebase流程尝试调用firebase_appdistribution时,就会出现“方法未定义”的错误。

Android Fastfile文件

# android/fastlane/Fastfile

default_platform(:android)

platform :android do
  desc "将新的测试版本提交到Firebase App Distribution"
  lane :firebase do
    notes = ENV["RELEASE_NOTES"]
    if notes.nil? || notes.strip.empty?
      file_path = File.join(Dir.pwd, "..", "release_notes.txt")
      if File.exist?(file_path) && !File.read(file_path).strip.empty?
        notes = File.read(file_path)
      else
        notes = "新的构建文件由持续集成系统上传"
      end
    end

    firebase_app_distribution(
      app: ENV["FIREBASE_APP_ID_ANDROID"],
      apk_path: "../build/app/outputs/flutter-apk/app-release.apk",
      groups: "testers",
      release_notes: notes,
      service_credentials_file: ENV["FIREBASE_SERVICE_ACCOUNT_JSON_PATH"]
    )
  end

  desc "将应用部署到Google Play商店"
  lane :prod do
    upload_to_play_store(
      track: 'production',
      aab: "../build/app/outputs/bundle/release/app-release.aab",
      json_key: 'play-store-service-account.json',
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )
  end
end

default_platform(:android)这一行代码指定了默认的操作环境,让Fastlane知道当前正在处理一个Android项目。lane :firebase do定义了一个名为firebase的流程步骤序列。

在文件的开头部分,notes这段代码会优先从三个来源获取发布说明:首先是从环境变量RELEASE_NOTES中获取(当通过GitHub Actions手动触发工作流并且指定了相关参数时,这个变量就会被设置);其次是从项目根目录下的release_notes.txt文件中读取内容;如果前两个来源都没有提供有效信息,就会使用一个默认的备用字符串。而firebase_app_distribution(...)则是该插件提供的核心功能。

app: ENV["FIREBASE_APP_ID_ANDROID"]这一行代码指定了要上传到哪个Firebase应用上,这些信息是从工作流中设置的环境变量中获取的。apk_path指明了Flutter生成的可执行APK文件的位置。groups: "testers"指定了在Firebase App Distribution中要通知的测试人员组别。请根据实际情况替换这个值。对于prod流程来说,upload_to_play_store(...)是Fastlane内置提供的动作。track: 'production'表示将应用上传到生产环境对应的存储区。skip_upload_metadata: trueskip_upload_images: true以及skip_upload_screenshots: true这些选项的作用是防止Fastlane尝试管理与应用商店相关的信息,因为这些功能并不属于这个构建流程的职责范围。

为iOS设置Fastlane

由于代码签名的原因,iOS的配置过程比Android更为复杂。`ios/`目录需要单独的Fastlane配置文件。

iOS的Gemfile文件

# ios/Gemfile

source "https://rubygems.org"
gem "fastlane"

plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)

其结构与Android的Gemfile文件完全相同。iOS和Android使用不同的Bundler环境,因为它们的目录位置不同,可能也需要不同的gem版本或插件。在`ios/`目录下运行`bundle install`命令时,安装的gem会与`android/`目录中安装的gem相互独立。

iOS的Appfile文件

# ios/fastlane/Appfile

app_identifier("com.yourcompany.app")

`app_identifier(...)`用于指定iOS应用的标识符。这个标识符必须与Xcode中设置的值完全一致(可以在“运行器目标”的“一般”选项卡中查看)。请将`com.yourcompany.app`替换为你的实际应用标识符。Fastlane Match在生成证书和配置文件时,会使用这个标识符。

Matchfile文件

# ios/fastlane/Matchfile

git_url(ENV["MATCH_GIT_URL"] || "https://github.com/YOUR_GITHUB_USERNAME/your-certificates-repo")
storage_mode("git")
type("appstore")

`git_url(...)`用于指定私有证书仓库的位置。在GitHub Actions的工作流程中,`MATCH_GIT_URL`环境变量会包含个人访问令牌,这样Match才能成功访问私有仓库。当在本地运行Match时,如果未设置该环境变量,系统会要求用户手动输入凭据。`storage_mode("git")`表示使用Git作为存储后端,而不是S3或Google Cloud Storage。`type("appstore")`用于指定默认的证书类型,不过每个Fastlane通道都可以自行修改这个设置。

iOS的Pluginfile文件

# ios/fastlane/Pluginfile

gem 'fastlane-plugin-firebase_app_distribution'

在iOS平台上,用于上传ad-hoc IPA文件到Firebase的`firebase`通道也需要使用相同的Firebase App Distribution插件。iOS和Android的Pluginfile文件是分开的,因此都需要包含这个插件的声明。

iOS的Fastfile文件


# ios/fastlane/Fastfile

default_platform(:ios)

before_all do
  setup_ci
end

platform :ios do
  desc "将新的测试版版本推送到TestFlight"
  lane :beta do
    api_key = app_store_connect_api_key(
      key_id: ENV["APP_STORE_CONNECT_API_KEY_KEY_ID"],
      issuer_id:ENV["APPSTORE_CONNECT_API_KEY_ISSUER_ID"],
      keyfilepath: ENV["APP STORE_CONNECT_API_KEY_KEY_FILEPATH"],
      in_house: false
    )

    match(
      type: "appstore",
      readonly: false,
      app_identifier: "com.YOUR-APP.app",
      api_key: api_key
    )

    update_code_signing_settings(
      path: "Runner.xcodeproj",
      use_automatic_signing: false,
      team_id: "GL369K3W98",
      code_sign_identity: "Apple Distribution",
      profile_name: "match AppStore com.YOUR-APP.app",
      targets: ["Runner"]
    )

    build_app(
      workspace: "Runner.xcworkspace",
      scheme: "Runner",
      export_method: "app-store"
    )

    notes = ENV["RELEASE_NOTES"]
    if notes.nil? || notes.strip.empty?
      file_path = File.join(Dir.pwd, "..", "release_notes.txt")
      if File.exist?(file_path) && !File.read(file_path).strip.empty?
        notes = File.read(file_path)
      else
        notes = "新的构建版本由持续集成系统上传"
      end
    end

    upload_to_testflight(
      skip_waiting_for_build_processing: true,
      changelog: notes
    )
  end

  desc "部署到Apple App Store"
  lane :prod do
    api_key = app_store_connect_api_key(
      key_id: ENV["APP_STORE_CONNECT_API_KEY_KEY_ID"],
      issuer_id:ENV["APPSTORE_CONNECT_API_KEY_ISSUER_ID"],
      keyfilepath: ENV["APP STORE_CONNECT_API_KEY_KEY_FILEPATH"],
      in_house: false
    )

    match(
      type: "appstore",
      readonly: false,
      app_identifier: "com.YOUR-APP.app",
      api_key: api_key
    )

    update_code_signing_settings(
      path: "Runner.xcodeproj",
      use_automatic_signing: false,
      team_id: "GL369K3W98",
      code_sign_identity: "Apple Distribution",
      profile_name: "match AppStore com.YOUR-APP.app",
      targets: ["Runner"]
    )

    build_app(
      workspace: "Runner.xcworkspace",
      scheme: "Runner",
      export_method: "app-store"
    )

    upload_to_app_store(
      force: true, # 跳过HTML报告生成
      submit_for_review: false, # 直接上传到App Store Connect,不自动提交审核
      automatic_release: false
    )
  end

  desc "将新的测试版版本推送到Firebase App Distribution"
  lane :firebase do
    api_key = app_store_connect_api_key(
      key_id: ENV["APP_STORE_CONNECT_API_KEY_KEY_ID"],
      issuer_id:ENV["APPSTORE_CONNECT_API_KEY_ISSUER_ID"],
      keyfilepath: ENV["APP STORE_CONNECT_API_KEY_KEY_FILEPATH"],
      in_house: false
    )

    match(
      type: "adhoc",
      readonly: false,
      app_identifier: "com.YOUR-APP.app",
      api_key: api_key
    )

    update_code_signing_settings(
      path: "Runner.xcodeproj",
      use_automatic Signing: false,
      team_id: "GL369K3W98",
      code_sign_identity: "Apple Distribution",
      profile_name: "match AdHoc com.YOUR-APP.app",
      targets: ["Runner"]
    )

    build_app(
      workspace: "Runner.xcworkspace",
      scheme: "Runner",
      export_method: "ad-hoc"
    )

    notes = ENV["RELEASE_NOTES"]
    if notes.nil? || notes.strip.empty?
      file_path = File.join(Dir.pwd, "..", "release_notes.txt")
      if File.exist?(file_path) && !File.read(file_path).strip.empty?
        notes = File.read(file_path)
      else
        notes = "新的构建版本由持续集成系统上传"
      end
    end

    firebase_app_distribution(
      app: ENV["FIREBASE_APP_ID_IOS"],
      groups: "testers",
      release_notes: notes,
      service_credentials_file:ENV["FIREBASE_SERVICE_ACCOUNT_JSON_PATH"]
    )
  end
end
before_all do setup_ci end 这一指令会在所有测试环节之前执行。setup_ci 是 Fastlane 中内置的一个操作,它的作用是配置用于持续集成环境的相关设置:它会创建一个临时密钥链(这样在安装证书时就不会出现 macOS 要求输入密码的提示),禁用代码签名相关的弹窗,并配置其他与持续集成相关的选项。如果没有执行这个步骤,证书的安装过程将会因为用户需要点击一个永远不会出现的确认对话框而陷入停滞。 app_store_connect_api_key(...) 这个指令会读取 App Store Connect 的 API 密钥,并生成一个 API 密钥对象,后续的其他操作会使用这个对象来进行 App Store 验证。key_idissuer_idkey_filepath 这些参数都来自于工作流程中设置的环境变量。in_house: false 表示这是一个普通的开发者账户(而不是 Apple Enterprise Program 账户,后者有着不同的分发规则)。 match(type: "appstore", ...) 这个指令会连接证书仓库,下载 App Store 分发证书和配置文件,然后将它们安装到 macOS 的密钥链中。 readonly: false 如果证书还不存在,这个选项允许 Match 自动生成证书。对于一个新的项目来说,在第一次执行这个指令时,Match 会生成证书并将其上传到仓库中;之后的执行过程只需下载已经存在的证书即可。对于 firebase 测试环节来说,会使用 type: "adhoc" 这个选项,因为 Firebase App Distribution 需要的是临时分配的证书,而不是 App Store 的证书。 update_code_signing_settings(...) 这个指令会修改 Xcode 项目文件,使其使用 Match 刚刚下载到的证书和配置文件。 use_automatic_signing: false 这个选项非常重要:如果启用自动签名功能,Xcode 会尝试自行管理证书,但在没有图形界面的持续集成环境中,这种操作是无法正常进行的。team_id: "YOUR_team_ID" 这里填写的是你的 Apple 开发者团队 ID,这个信息可以在 Apple 开发者门户的“会员信息”板块中找到。profile_name: "match AppStore com.yourcompany.app" 这个参数遵循 Match 在创建配置文件时所使用的命名规则。 build_app workspace: "Runner.xcworkspace", scheme: "Runner", export_method: "app-store") 这个指令会调用 xcodebuild 命令来打包并导出应用程序。Runner.xcworkspace 是 Flutter 生成的 Xcode 工作区文件。当项目中包含了 CocoaPods 依赖项时,就必须使用工作区文件而不是项目文件本身。export_method: "app-store" 这个参数指定了最终生成的 IPA 文件应使用哪种导出格式。对于 Firebase 测试环节来说,应该使用 "ad-hoc" 这种格式。 upload_to_testflight(skip_waiting_for_build_processing: true) 这个指令会将生成的 IPA 文件上传到 App Store Connect 平台。skipwaiting_for_build_processing: true 这个选项告诉 Fastlane 不需要等待 Apple 完成构建过程的处理,因为这个过程可能需要 15 到 30 分钟的时间。一旦上传完成,整个工作流程也就结束了。等到 Apple 完成他们的处理后,该应用程序就会出现在 TestFlight 平台上。

upload_to_app_store FORCE: true, submit_for_review: false, automatic_release: false) 该命令会将应用程序上传到 App Store Connect,以便进行正式发布。force: true 会跳过 Fastlane 生成的 HTML 总结报告,因为在持续集成环境中这份报告并无实际用途。submit_for_review: false 表示在上传构建结果后不会自动提交审核流程,这样你就可以先自行检查后再进行提交。automatic_release: false 可以防止应用程序在获得审核通过后自动发布。

编写 GitHub Actions 工作流

工作流其实是存储在仓库根目录下的 .github/workflows/ 文件中的 YAML 格式文件。每个工作流文件都会定义一个工作流的名称、触发该工作流的事件类型,以及需要执行的步骤顺序。

Android 工作流

# .github/workflows/android_distribution.yml

name: Android Firebase 应用程序发布流程
on:
  push:
    branches:
      - dev
      - prod
  workflow_dispatch:
    inputs:
      release_notes:
        description: '发布说明'
        required: false
        default: '由 GitHub Actions 手动触发'

jobs:
  distribute_android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v3
        with:
          distribution: 'zulu'
          java-version: '17'

      - uses: subosito/flutter-action@v2
        with:
          channel: 'stable'
          cache: true

      - run: flutter pub get

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true
          working-directory: android

      - name: 解码密钥库文件
        env:
          ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTOREBASE64 }}
        run: |
          echo $ANDROID_KEYSTORE_BASE64 | base64 --decode > android/app/upload-keystore.jks
          echo "storeFile=upload-keystore.jks" > android/key.properties
          echo "storePassword=${{ secrets.ANDROID_STORE_PASSWORD }}" >>> android/key.properties
          echo "keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}" >>> android/key.properties
          echo "keyAlias=${{ secrets.ANDROID_KEY_alias }}" >>> android/key.properties

      - name: 创建 .env 文件
        env:
          ENV_FILE_BASE64: ${{ secrets.ENV_FILE_BASE64 }}
        run: echo $ENV_FILE_BASE64 | base64 --decode > .env

      - name: 构建 Android 发布版本
        run: |
          if [ "${{ github.ref_name }}" == "prod" ]; then
            flutter build appbundle --release
          else
            flutter build apk --release
          fi

      - name: 生成 Firebase 服务账户 JSON 文件
        if: ${{ github/ref_name == 'dev' }}
        env:
          FIREBASE_SERVICE_ACCOUNT_JSON: ${{ secrets.FIREBASE_SERVICEACCOUNT.JSON }}
        run: echo $FIREBASE_SERVICE ACCOUNT_json > android/firebase-service-account.json

      - name: 在开发环境中将应用程序发布到 Firebase App Distribution
        if: ${{ github.ref_name == 'dev' }}
        env:
          FIREBASE_APP_ID_ANDROID: ${{ secrets.FIREBASE_APP_IDANDROID }}
          FIREBASE_SERVICE_ACCOUNT_JSON_PATH: "firebase-service-account.json"
          RELEASE_NOTES: ${{ github.event.inputs.release_notes }}
        run: bundle exec fastlane firebase
        working-directory: android

      - name: 在正式环境中将应用程序发布到 Google Play Store
        if: ${{ github.ref_name == 'prod' }}
        env:
          GOOGLE_PLAY_JSON: ${{ secrets.GOOGLE_Play.JSON }}
        run: |
          echo $GOOGLE_play_json > play-store-service-account.json
          bundle exec fastlane prod
        working-directory: android
name: Android Firebase应用分发 是在您仓库的GitHub Actions选项卡中显示的名称。 on: push: branches: [dev, prod] 用于配置触发条件。每当有提交被推送到devprod分支时,这个工作流就会运行。其他分支,包括maindevelop,都不会触发此工作流,因为这些分支属于仅用于测试的阶段分支。 workflow_dispatch: inputs: release_notes 可以添加手动触发机制。在GitHub Actions选项卡中,您可以点击“运行工作流”,并根据需要输入发布说明,这些信息会被传递给Fastlane。这对于进行测试或进行临时性发布非常有用。 runs-on: ubuntu-latest 指定了使用的虚拟机。之所以选择Ubuntu,是因为Android构建工具链是在Linux环境下运行的,而且使用Ubuntu作为运行环境比使用macOS更经济实惠。 actions/checkout@v4 会将您的仓库克隆到虚拟机的工作目录中。如果没有这个步骤,其他后续操作就无法访问您的代码。 actions/setup-java@v3 使用Zulu发行版安装Java 17。当前大多数Flutter项目都在使用Gradle 8,而Java 17是确保Gradle 8能够正常运行的必备版本。如果没有正确的Java版本,Gradle会立即出现运行错误。 subosito/flutter-action@v2 用于安装Flutter SDK。channel: 'stable'表示使用稳定版发布渠道,这对于生产环境的构建来说是非常合适的。cache: true则可以在多次运行工作流时重复利用已下载的Flutter SDK文件,从而大大缩短后续运行的准备时间。 ruby/setup-ruby@v1 安装Ruby 3.2,并且当设置bundler-cache: true时,会自动在android/目录中执行bundle install命令。bundler-cache选项还可以在多次运行过程中重复使用已安装的Ruby gem包,这样每次运行工作流时都能节省两到三分钟的时间。 Decode Keystore 这一步骤是Android安全配置的核心。命令echo $ANDROID_KEYSTORE_BASE64 | base64 --decode > android/app/upload-keystore.jks用于将Base64编码格式转换回二进制文件,从而生成位于指定路径的.jks文件。后续的echo命令会生成key.properties文件,Android Gradle在构建过程中会读取这个文件来获取密钥库文件及其密码。这个文件每次运行工作流时都会根据 secrets中的信息重新生成,因此永远不会被永久保存在任何地方。 if [ "${{ github.ref_name }}" == "prod" ] 是一个Bash条件语句。github/ref_name表示触发本次推送操作的分支名称。如果分支是prod,那么工作流会生成App Bundle文件(格式为.aab,适用于Play Store);否则(如果是dev分支),则会生成APK文件(格式为.apk,这种格式更简单、生成速度更快,适合用于Firebase应用分发)。通过这个条件语句,同一个工作流脚本能够同时处理这两种不同的分支情况。if: ${{ github.ref_name == 'dev' }}是一种基于步骤条件的判断机制。只有当触发分支为dev时,包含这一条件的步骤才会被执行。在针对prod分支进行的推送操作中,Firebase相关的分发步骤会被完全跳过;而在针对dev分支进行的推送操作中,Play Store相关的步骤也会被完全忽略。

iOS工作流程

# .github/workflows/ios_distribution.yml

name: iOS TestFlight与Firebase应用分发
on:
  push:
    branches:
      - dev
      - prod
  workflow_dispatch:
    inputs:
      release_notes:
        description: '发布说明'
        required: false
        default: '通过GitHub Actions手动触发'

jobs:
  distribute_ios:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v3
        with:
          distribution: 'zulu'
          java-version: '17'

      - uses: subosito/flutter-action@v2
        with:
          channel: 'stable'
          cache: true

      - run: flutter pub get

      - name: 创建.env文件
        env:
          ENV_FILE_BASE64: ${{ secrets.ENV_FILE_BASE64 }}
        run: echo $ENV_FILE_BASE64 | base64 --decode > .env

      - name: 构建Flutter iOS版本(不进行代码签名)
        run: flutter build ios --release --no-codesign

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true
          working-directory: ios

      - name: 配置Fastlane匹配信息
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secretsMATCH_GIT_BASICAUTHORIZATION }}
        run: |
          echo "MATCH_PASSWORD=${MATCH_PASSWORD}" >> $GITHUB_ENV
          AUTH=$(echo "$MATCH_GIT_BASIC_AUTHIZATION" | base64 --decode)
          echo "MATCH_GIT_URL=https://$AUTH@github.com/YOUR_Github_USERNAME/your-certificates-repo" >> $GITHUB_ENV

      - name: 为App Store Connect创建认证密钥
        env:
          APPSTORE_API_PRIVATE_KEY_BASE64: ${{ secrets.APPSTORE_API/Private_KEY_BASE64 }}
          APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
        run: |
          mkdir -p ~/.appstoreconnect/private_keys/
          echo $APPSTORE_API_private_KEY_BASE64 | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${APPSTORE_API_KEY_ID}.p8

      - name: 生成Firebase服务账户JSON文件
        if: ${{ github.ref_name == 'dev' }}
        env:
          FIREBASE_SERVICE_ACCOUNT_JSON: ${{ secrets.FIREBASE_SERVICE ACCOUNT.JSON }}
        run: echo $FIREBASE_SERVICEACCOUNT_json > ios/firebase-service-account.json

      - name: 将应用分发到Firebase App Distribution(开发环境)
        if: ${{ github.ref_name == 'dev' }}
        env:
          FIREBASE_APP_ID_IOS: ${{ secrets.FIREBASE_APP_ID-ios }}
          FIREBASE_SERVICE ACCOUNT_JSON_PATH: "firebase-service-account.json"
          RELEASE_NOTES: ${{ github.event.inputs.release_notes }}
          APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APPSTORE ISSUER ID }}
          APP STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
          APP_store_CONNECT_API_KEY_KEY_FILEPATH: ~/.appstoreconnect/private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8
        run: bundle exec fastlane firebase
        working-directory: ios

      - name: 将应用分发到TestFlight(开发环境)
        if: ${{ github.ref_name == 'dev' }}
        env:
          APP STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APPSTORE ISSUER ID }}
          APP STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
          APP STORE_CONNECT_API_KEY_KEY_FILEPATH: ~/.appstoreconnect/private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8
        run: bundle exec fastlane beta
        working-directory: ios

      - name: 将应用分发到Apple App Store(生产环境)
        if: ${{ github.ref_name == 'prod' }}
        env:
          APP STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APPSTORE ISSUER ID }}
          APP STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
          APP STORE_CONNECT_API_KEY_KEY_FILEPATH: ~/.appstoreconnect/private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8
        run: bundle exec fastlane prod
        working-directory: ios

runs-on: macos-latest这一要求对于iOS构建来说是不可更改的。Xcode仅能在macOS上运行,而xcodebuild(Fastlane在内部使用的工具)也仅在macOS上可用。与Ubuntu相比,macOS构建环境的成本每分钟要高出大约十倍,因此Android项目才会选择使用Ubuntu。但对于iOS来说,没有其他替代方案。

flutter build ios --release --no-codesign这条命令会将Flutter的Dart代码以及原生iOS框架代码编译成发布版本,且不会进行任何代码签名操作。--no-codesign这个参数非常重要:因为此时签名证书尚未安装,所以Flutter的构建过程不应该尝试进行签名操作。Fastlane Match会在后续的处理步骤中下载并安装正确的签名证书,从而完成签名工作。

“配置Fastlane Match”这一步骤的作用非常关键。AUTH=$(echo "$MATCH_GIT_BASIC_AUTHORIZATION" | base64 --decode)这条命令会将Base64编码的username:token字符串解码成明文。echo "MATCH_GIT_URL=https://$AUTH@github.com/..." >> $GITHUB_ENV这条命令会将包含token的授权URL写入$GITHUB_ENV文件中,GitHub Actions会读取这个文件,从而将相应的环境变量传递给后续步骤。这种授权URL的格式https://username:token@github.com/...属于HTTP Basic Authentication类型,Git在非交互式环境中就是使用这种格式来传递认证信息的。

“创建认证密钥”这一步骤的作用是重新生成.p8格式的密钥文件。mkdir -p ~/.appstoreconnect/private_keys/这条命令会创建Fastlane所需用来存储密钥的目录。echo $APPSTORE_API_PRIVATE_KEY_BASE64 | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${APPSTORE_API_KEY_ID}.p8这条命令会将解码后的密钥写入与app_store_connect_api_key命令所期望的文件名格式完全匹配的文件中。

对于dev分支来说,iOS开发流程会同时执行两个并行分布任务:firebase通道会生成ad-hoc格式的IPA文件并上传到Firebase App Distribution平台,而beta通道则会生成App Store兼容的IPA文件并上传到TestFlight平台。这两个任务都会在共享的设置步骤之后依次执行。因此,只要向dev分支进行一次推送操作,构建好的应用程序就会自动被发送到这两个分发渠道中。

截图:

Android和iOS开发流程运行中的状态:

Android和iOS开发流程运行中

Android开发流程完成后的状态:

Android开发流程完成后的状态

已完成的iOS工作流程:

已完成的iOS工作流程

同时完成的Android和iOS工作流程:

同时完成的Android和iOS工作流程

Firebase应用分发——Android版本:

Firebase应用分发——Android版本

Firebase应用分发——iOS版本:

Firebase应用分发——iOS版本

用于TestFlight的iOS版本构建:

用于TestFlight的iOS版本构建

完整的部署流程是如何运作的

当所有配置都准备就绪后,从将代码推送到dev分支开始,整个过程会按照以下顺序进行:

工作流程图:开发人员将代码推送至dev分支后,GitHub Actions会自动并行启动两个工作流程:一个在Ubuntu环境下运行的Android工作流程,另一个在macOS环境下运行的iOS工作流程。Android工作流程会下载代码、安装Java和Flutter工具、恢复项目依赖关系、解码Android密钥库及环境配置文件,然后生成APK文件,并通过Fastlane将其上传到Firebase应用分发平台;iOS工作流程也会下载代码、安装相应的开发工具、恢复依赖关系、解析环境变量,随后生成iOS应用程序(此时不会进行代码签名),再利用Fastlane Match获取签名证书,加载App Store API密钥,最终生成用于Firebase应用分发的Ad-Hoc版本以及用于TestFlight的App Store版本。整个流程结束后,Android测试人员会收到Firebase应用分发的电子邮件通知,而iOS测试人员则会收到TestFlight发送的邀请邮件。

这两个工作流程是并行执行的,因此整个过程所花费的总时间大致取决于耗时较长的那个平台——通常情况下,由于Xcode的编译时间较长,iOS版本的部署会花费更长时间。

对于向prod分支推送代码的情况,整个流程的结构是相同的,但最终的分发步骤会将应用上传到Google Play Store(针对Android设备)或App Store Connect(针对iOS设备)。

最佳实践

保持证书仓库的私密性并控制访问权限

证书仓库中存放着使用“Match”密码加密后的iOS签名文件。尽管这些文件已经经过加密处理,但对待对这一仓库的访问仍应像对待生产环境中的数据库一样谨慎。对于不再需要的个人访问令牌,应及时予以撤销;切勿以明文形式在任何地方分享“Match”密码。

设定最低构建编号策略

自动化的持续集成流程要求每次上传时都必须使用唯一的构建编号。App Store Connect和Google Play都会拒绝那些具有重复构建编号的上传文件。因此,需要实施一种无需人工干预的版本控制机制。一个可靠的方法是使用GitHub Actions中的`GITHUB_RUN_NUMBER`变量——这个整数会在每次工作流运行时递增:

- name: 设置构建编号
  run: |
    BUILD_NUMBER=${{ github.run_number }}
    # 对于Flutter项目,需要在pubspec.yaml文件中更新构建编号
    sed -i '' "s/version: .*/version: 1.0.0+${BUILD_NUMBER}/" pubspec.yaml

`github.run_number`是GitHub提供的一个环境变量:对于仓库中的第一个工作流运行,该变量的初始值为1;此后每次运行时,其值都会增加1。这样一来,所有构建过程的编号都能保持唯一且呈递增趋势。`sed`命令会将`pubspec.yaml`文件中的版本信息替换为包含运行编号的新格式。

设置分支保护规则

在实现了自动化流程之后,还需要采取措施防止意外情况下直接对分支进行推送操作。在仓库的设置中,进入“分支”页面,为`main`、`develop`、`dev`和`prod`这些分支设置相应的保护规则。

特别是对于`prod`分支,可以考虑要求在合并代码之前必须获得至少一次拉取请求的批准,这样就能在正式发布之前设置一道人为审核关卡。

监控工作流的运行时间与成本

GitHub Actions的费用是根据运行所消耗的分钟数来计算的。macOS平台上的运行费用是Linux平台的10倍。请前往您所在GitHub组织的“设置”页面,然后选择“计费”选项,查看当前的使用情况。

缓存机制是效果最为显著的优化手段。对于Flutter项目来说,只需将`cache: true`这个配置添加到脚本中;而对于Ruby项目,则需要设置`bundler-cache: true`。在第一次运行之后,后续的运行过程如果能够利用到缓存数据,就可以完全跳过下载和提取文件的步骤。

将发布说明保存在文件中,而不仅仅是作为输入参数

在Fastfile配置文件中,`release_notes.txt`这个文件被设置为默认的发布说明存储位置。这意味着您可以在提交拉取请求时同时附上发布说明,而这些说明会自动出现在Firebase和TestFlight发布的通知中。请在项目根目录下创建这个文件,并在每次发布新版本时更新其中的内容。这样,发布说明就能与所描述的代码一起被保存在版本历史记录中了。

常见错误

在Fastlane中使用Xcode项目而非工作区

Flutter iOS项目始终使用工作区(Runner.xcworkspace),而不是项目文件(Runner.xcodeproj),因为CocoaPods依赖关系是在工作区层面进行配置的。如果将Runner.xcodeproj传递给build_app命令,会因为缺少依赖项而失败。因此,请务必使用workspace: "Runner.xcworkspace"

未为iOS设置setup_ci

如果在before_all块中省略了setup.ci,那么当macOS等待keychain访问权限时,工作流程会无限期地停滞下去,因为这种等待永远不会结束。这看起来像是超时错误,但实际上问题出在设置上。因此,在任何用于持续集成测试的iOS Fastfile中,都必须包含before_all do setup_ci end这段代码。

在新项目中以只读模式运行Match工具

当首次使用新的应用标识符运行Match工具时,系统需要生成证书和配置文件。如果将readonly: true设置为true,Match工具就无法生成这些文件,从而会出现“未找到证书”的错误。因此,请务必将readonly: false设置为false。在生产环境中,有些团队会在初始设置完成后将readonly: true设置为true,以防止证书被意外重新生成,但在这种初始设置阶段,使用readonly: false才是正确的做法。

忘记增加构建编号

无论是Apple还是Google,都会拒绝那些版本号与之前上传过的构建版本相同的构建请求。如果你在没有增加构建编号的情况下将代码两次推送到dev分支,第二次推送就会失败。GITHUB_RUN_NUMBER这一最佳实践机制可以自动避免这种情况的发生。

在文件编码时添加尾随换行符

如果使用echo "content" | base64而不是echo -n "content" | base64进行编码,那么在编码之前字符串的末尾会添加一个换行符。当在持续集成环境中解码这些文件时,文件中就会包含这个原本不存在的换行符。对于MATCH_GIT_BASIC_AUTHORIZATION中的username:token字符串来说,尾随的换行符会导致认证失败,而这种错误看起来就像是权限问题。因此,在对非文件类型的字符串进行编码时,请务必使用echo -n命令。

为Firebase选择了错误的分发类型

iOS版本的Firebase应用分发需要使用ad-hoc分发证书,而不是App Store证书。将用App Store签名的IPA文件上传到Firebase是会失败的,因为ad-hoc构建版本就是专门用于在App Store之外直接在设备上分发的。正因如此,在iOS Fastfile中的firebase流程中明确指定了type: "adhoc"export_method: "ad-hoc"这些参数。beta流程则使用type: "appstore",因为TestFlight需要App Store证书才能正常运行。

为Google Play服务账户授予不足的权限

在将应用上传到Play Store时,最常见的失败原因是API权限设置错误。该服务账户必须至少被配置为具有“发布管理员”权限,才能与您的Play Console应用关联起来。在Google Cloud中创建服务账户仅仅完成了设置过程的一半工作——您还必须在Play Console中为其配置API访问权限。如果忽略这一步骤,Fastlane在尝试上传应用时就会遇到403 Forbidden错误。

结论

您所建立的这套基础设施能够带来持续的收益。当您第一次将代码推送到dev环境,然后看到GitHub Actions界面中同时显示Android和iOS版本的构建过程已完成时,就会立刻感受到这套系统的价值。无论是第四次、第十次还是第五十次进行这样的操作,其带来的好处都会持续累积——因为整个部署过程是在您毫无察觉的情况下自动完成的。

本指南中介绍的架构涵盖了常见的使用场景,但所使用的工具(如GitHub Actions、Fastlane、Match)具有很高的灵活性,几乎可以适应任何类型的工作流程。团队可以根据实际需求添加自动化测试步骤,在构建完成后发送Slack通知,通过Git标签来管理版本号,同时还可以支持除了devprod之外的其他目标环境。您所建立的这个基础框架完全能够支撑这些扩展功能。

其中有一条原则尤其值得强调:请像对待生产环境中的代码一样谨慎地处理您的CI配置文件。在提交修改请求时,一定要仔细审核工作流程文件中的变更内容;对于那些不太显而易见的步骤,也要添加注释说明。同时,要将敏感信息妥善保管在专门的保密仓库中,而不要放在工作流程文件中。如果工作流程文件存在问题,就会导致构建失败——这与生产环境中的代码出错的原因是一样的:未经审核的修改、缺失的上下文信息以及未记录的假设条件等。

一旦建立了这样的工作流程,您的团队就能更快、更自信地发布新版本的应用程序,因为将代码交付给测试人员的过程不再是一个繁琐且容易出错的手动操作。这应该就是代码提交所带来的自然结果吧。

参考资料

GitHub Actions

Fastlane

  • Fastlane文档
    涵盖了所有Fastlane命令的详细说明,包括upload_to_testflightupload_to_play_storematchbuild_app等。

  • Fastlane Match文档
    关于代码签名管理系统的详细说明,包括初始设置及证书更新流程。

  • firebase_app_distribution插件文档
    该插件为Fastlane添加了firebase_appdistribution命令,便于进行应用分发操作。

Apple

Google

Flutter

相关文章

技术实践

如何利用Claude与MCP构建以隐私保护为首要目标的医学图像去标识化系统

想象一下,让人工智能助手来处理成千上万的医学图像。它能够运行整个处理流程,跟踪进度,总结每一项决策,并告诉你哪些文件需要人工审核——而这一切过程,它根本不需要看到任何患者的个人信息。 乍一听,这似乎是不可能的。因为通常情况下,人工智能助手需要访问它们所要帮助处理的数据。 在这个教程中,你将构建一个不会直接查看敏感医学图像的人工智能系统。相反,它会通过精心设计的工具来协调整个去标识化处理流程,确保患者的所有数据都保留在你的机器上。 使这一切成为可能的技术是“模型上下文协议”(Model Context Protocol,简称MCP)。这一开放标准允许人工智能模型调用外部工具,而不仅仅依赖它们内置

阅读全文
技术实践

如何让你的反重力技能具备可配置性(同时避免出现代码分支问题)

“反重力智能体技能”是一种非常有效的方法,可以帮助你一次性为人工智能智能体设定工作流程,并让它在各种场景中都能被重复使用。你只需编写一个简短的`SKILL.md`文件,将其放入相应的文件夹中,智能体在需要使用时就会自动加载这些配置。 然而,这类技能存在一个隐藏的局限性:它们是静态的。如果你下载了别人编写的技能代码,但希望它的行为有所改变,你就必须手动复制整个代码并进行修改。而且,最近你也应该注意到了,市面上有很多经过分叉修改的“技能版本”,这些版本往往很难进行维护。 在本次教程中,我将向你展示一种解决方法。这种方法可以让任何智能体技能读取特定项目中的配置文件,因此你完全可以使用现有的技能,只需

阅读全文
技术实践

如何使用针对用户的OAuth访问机制来构建人工智能代理程序【完整手册】

当你的AI代理同时为多个人提供服务时,每一次工具调用都必须明确:该代理究竟是在代表哪位用户行事。让我们通过构建一个能够与Slack和GitHub连接的AI代理来学习如何解决这个问题。 当使用Slack时,系统会使用 해당用户的 workspace;而在GitHub上创建问题时,也会以该用户的身份在其有权访问的仓库中操作。虽然代理可能会犯错,但它绝对不能使用错误用户的权限来进行操作。 解决这个问题的方法分为两个部分,而这两个部分都在本教程的前半部分进行了讲解: 每位用户都需要单独授权。 Alice为自己授权Slack,Bob也为自己授权Slack。 代理传递的是标识符,而不是令牌。 像 alic

阅读全文
技术实践

如何让你的副业项目被人们注意到,并吸引到愿意付费使用的用户

2022年,我在业余时间开发了一个小型微服务产品,最终以几千美元的价格将其卖了出去。如今,有了人工智能工具的帮助,开发这样的产品可能会更加容易。 但真正发生巨大变化的是获取关注的成本,而不是开发软件本身的成本。 我认为,在2026年,产品的分发渠道将比开发本身更为重要。在这篇文章中,我会与大家分享我在产品开发过程中所学到的经验,并试图劝阻大家在开始下一个项目之前,先不要急着直接投入编码工作。 读完这份指南后,你应该能够掌握一些实用的方法和思路,这些方法可以帮助你将自己那些充满热情的项目推向市场。 需要明确的是,这篇文章主要是针对那些正在开发数字产品的人,尤其是软件产品。不过,这些概念同样适用于

阅读全文