多年来,移动应用程序的开发方式一直在不断演变。我们所使用的流程、结构以及语法都发生了变化,同时,我们开发的应用程序在质量和灵活性方面也有了显著提升。
其中一个重要的改进就是实现了自动化程度较高的CI/CD管道流程,这使得我们可以实现无缝的自动化操作、持续集成以及持续部署。
在本文中,我将详细说明如何使用GitHub Actions为你的Flutter应用程序构建一个可用于生产环境的CI/CD管道。
需要注意的是,还有其他方法也可以实现这一目标,比如使用Codemagic(这款工具是专门为Flutter应用程序设计的——我会在后续的教程中介绍它),但在本文中,我们主要会讨论如何使用GitHub Actions。
目录
典型的工作流程
首先,让我们明确一下部署可用于生产环境的Flutter应用程序的常见方法。
开发团队会在本地完成开发工作,然后将代码推送到仓库以便其他成员进行合并或审查。最后,他们会运行`flutter build apk`或`flutter build appbundle`命令来生成apk文件。这些文件要么会手动交给质量保障团队进行测试,要么会被部署到Firebase应用分发平台上进行测试。如果是要正式发布到生产环境,那么app bundle文件就需要提交给Google Play商店进行审核,之后才能正式发布。
目前,这个过程往往完全是手工完成的,没有任何自动化检查、验证机制,也无法对质量、速度或流程的顺畅性进行有效控制。虽然最初手动部署Flutter应用程序看起来很简单,但这种做法很快就会带来各种问题。你需要运行`flutter build`命令,切换配置文件,为生成的文件签名,然后将其上传到目标位置——同时还要确保没有将测试环境使用的密钥与生产环境使用的密钥搞混。
<随着团队规模的扩大,以及更新发布的频率越来越高,这些手动操作步骤实际上已经变成了真正的风险。如果跳过了质量检测环节,或者遗漏了密钥库文件,又或者将错误的基URL部署到了生产环境中,那么将会耗费大量的时间来进行调试——更糟糕的是,这些错误可能会影响到你的用户。>
要完全自动化这一流程,就需要进行一些高级配置并编写预定义的脚本。从开发者将 Pull Request 提交到公共分支或基础分支(例如 develop 分支)的那一刻起,这个自动化流程就会全面掌控整个部署过程。
只要这些操作已经被预先定义好、通过适当的脚本实现,并且符合团队的使用需求,那么这个自动化流程就能处理所有需要完成的任务。
我们在这里要做什么:
在本教程中,我们将使用 GitHub Actions 为 Flutter 应用构建一个适用于生产环境的 CI/CD 流程。该流程能够自动化整个应用生命周期:从处理 Pull Request 的质量检查,到根据不同环境进行配置设置,再到生成 Android 和 iOS 版本的应用程序,通过 Firebase App Distribution 将应用程序分发给测试人员,上传 Sentry 监控数据,最后将应用程序发布到 Play Store 和 App Store。
等到教程结束时,从开发者提交 Pull Request 开始,到最终版本应用于用户手中,整个流程都将实现完全自动化,根本不需要任何人去操作终端命令。
先决条件
在开始之前,您需要具备以下条件:
-
一个已经能够生成正常运行的 Android 和 iOS 版本应用程序的 Flutter 项目
-
对 GitHub Actions(工作流和任务)有基本的了解
-
一个已启用 App Distribution 功能的 Firebase 项目
-
一个用于错误跟踪的 Sentry 项目
-
已经创建好的 Google Play Console 应用程序账户
-
拥有 App Store Connect 访问权限的 Apple Developer 账户
-
为您的 iOS 项目配置好了 Fastlane 工具
-
具备基本的 Bash 命令知识(我会解释其中的重要部分)
流程架构
在本指南中,我们将根据非常明确的指导原则和使用场景来构建 CI/CD 流程。这些使用场景决定了流程的具体构建方式。
对于本教程来说,我们将会采用以下使用场景:
-
当团队中的开发者将 Pull Request 提交到公共工作分支
develop时,会触发一个工作流来对代码进行质量检查。只有当所有检查都通过后,代码才能被合并。 -
从
develop分支转移到staging分支的代码会进入另一个工作流,在这个工作流中,系统会注入相应的配置信息/密钥,进行所有必要的检查,并通过 Firebase App Distribution 将应用程序分发给 Android 测试用户,同时通过 Testflight 将 iOS 版本的应用程序分发给测试用户。 -
从
staging分支转移到production分支的代码会进入生产环境级别的工作流。这个工作流包括对 APK 文件进行安全签名处理,注入生产环境的配置信息,运行测试以确保应用程序能够正常运行,通过 Sentry 进行故障监控,最后将应用程序提交到 App Store Connect 和 Google Play Console。
这些是我们预先定义的条件,它们有助于构建我们的工作流程。
编写工作流程
我们将把这个处理流程分解为三个GitHub Actions工作流程。
为了使工作流程更加简洁、更易于维护,我们还会创建三个辅助.sh脚本。
在您的项目根目录下,创建两个文件夹:
-
.github/
-
scripts.
.github/文件夹将用于存放我们为不同场景创建的工作流程文件,而scripts/文件夹则用来保存那些可以在CLI中或直接在工作流程中调用的辅助脚本。
接下来,我们将创建三个工作流程.yml文件:
-
pr_checks.yaml
-
android.yaml
-
ios.yaml
同样在scripts文件夹中,我们还要创建三个.sh脚本:
-
generate_config.sh
-
quality_checks.sh
-
upload_symbols.sh
.github/
workflows/
prchecks.yaml
android.yaml
ios.yaml
scripts/
generate_config.sh
quality_checks.sh
uploadsymbols.sh
这种工作流程架构能够确保:每当有人将代码推送到develop分支时,系统会自动生成测试版本;而当代码被合并到production分支后,应用程序会直接被发布到应用商店,无需任何人工操作或配置更改。
这些辅助脚本被单独放在scripts文件夹中,这样就可以在本地环境中运行相同的逻辑。
辅助脚本
这些脚本是整个处理流程的核心。每个脚本都有明确的职责,并且可以在不同的工作流程中被重复使用。
我们不会把逻辑直接写进.yml文件中,而是将其放在可重用的脚本中。这样可以让工作流程保持整洁,同时也能在本地环境中运行相同的逻辑。现在我们来逐一了解这些脚本吧。
脚本#1:generate_config.sh
在移动应用开发中,安全地管理敏感信息是CI/CD流程中最为棘手的问题之一。
我们的解决方案如下:
-
提交一个包含占位符的Dart模板文件
-
在构建过程中使用GitHub Actions提供的敏感信息来替换这些占位符
-
绝对不要将真实的凭据提交到代码控制系统中
#!/usr/bin/env bash
set -euo pipefail
ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}
TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env.ci.g.dart"
if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
echo "Usage: $0 "
exit 2
fi
sed -e "s|<<BASE_URL>>|$BASE_URL|g" \
-e "s|<<ENCRYPTION_KEY>>|$ENCRYPTION_KEY|g" \
-e "s|<<ENV_NAME>>|$ENV_NAME|g" \
"(TEMPLATE) > "\)OUT"
echo "Generated config for $ENV_NAME"
这个脚本负责在构建过程中将特定于环境的配置信息插入到Flutter应用程序中,同时确保永远不会将敏感信息提交到代码控制系统中。
让我们仔细地分析一下这些内容。
1. Shebang:选择Shell解释器
#!/usr/bin/env bash
这一行命令告诉系统,无论Bash安装在机器的哪个位置,都应使用Bash来执行该脚本。
使用/usr/bin/env bash而不是/bin/bash,可以使脚本在不同的本地机器、GitHub Actions运行环境以及Docker容器中更加通用。
2. 快速失败、大声报警
set -euo pipefail
这是脚本中非常重要的一行代码。
它启用了三种严格的Bash运行模式:
-
-e:如果有任何命令失败,立即退出程序 -
-u:如果使用了未定义的变量,也会导致程序退出 -
-o pipefail:只要管道中的任何一条命令失败,整个脚本就会终止执行,而不仅仅是最后的那一条命令
在持续集成环境中,这一点非常重要——因为默默无声的错误可能会带来严重后果,部分配置信息的生成错误也可能会影响生产环境的构建过程。因此,当出现异常时,系统应该立即停止运行。
这一行代码能够确保任何有问题的配置信息都不会被用于构建过程。
3. 读取输入参数
ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}
这几行代码用于读取传递给脚本的位置参数:
-
$1:环境名称(如dev、staging或production) -
$2:API的基础URL -
$3:加密密钥或API密钥
${1:-}这种语法的含义是:
“如果参数缺失,系统会使用空字符串作为默认值,而不会导致程序崩溃。”
这一机制与set -u配合使用,能够让我们有意识地控制程序的异常退出情况,而不是让Bash自行产生不可预知的错误。
4. 定义输入文件和输出文件
TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env.ci.g.dart"
在这里,我们定义了两个文件:
-
模板文件(
env_ci.dart)-
其中包含一些占位符内容,比如
<<BASE_URL>> -
这种文件可以安全地提交到Git仓库中
-
-
生成后的文件(
env_ci.g.dart)-
其中会包含实际的环境配置信息
-
这种文件需要被Git忽略(在
.gitignore文件中添加相应规则)
-
这种处理方式的核心在于两个职责截然不同的Dart文件。虽然它们看起来相似,但在系统中扮演的角色却完全不同。
env_ci.dart:
// lib/core/env/env_ci.dart
class EnvConfig {
static const String baseUrl = '<<BASE_URL>>!';
static const String encryptionKey = '<<ENCRYPTION_KEY>>!';
static const String environment = '<<>ENV_NAME>>!';
}
这个文件是安全的、静态的,并且受到版本控制。其中包含的只是占位符,并非实际值。
它的一些关键特性如下:
-
不包含任何真正的机密信息
-
使用显而易见的占位符(如
等) -
可以安全地提交到Git中
-
会像普通源代码一样经过审查
-
它是所有配置字段的权威来源
可以把这个文件看作是一份“契约”:
“这些就是应用程序在运行时所期望的配置值。”
env.ci.g.dart:
这个文件是在构建阶段由generate_config.sh脚本生成的。经过替换处理后,它的内容如下:
// lib/core/env/env_ci.g.dart
// 生成文件——请勿提交到版本控制系统中
class EnvConfig {
static const String baseUrl = 'https://staging.api.example.com';
static const String encryptionKey = 'sk_live_xxxxx';
static const String environment = 'staging';
}
它的一些关键特性如下:
-
其中包含真实的环境配置值
-
是在持续集成流程中动态生成的
-
不同环境下的配置值是不同的(开发环境/测试环境/生产环境)
-
绝对不能被提交到版本控制系统中
如果这个文件是在本地生成的,那么它只存在于开发者的机器上;在构建过程中,它会在持续集成工具中生成,任务完成后就会消失。
.gitignore:
为确保这些生成的文件不会被泄露,我们必须将它们从版本控制系统中忽略掉:
为什么这种分离机制如此重要
这种设计能够同时解决多个复杂问题。
安全性:
-
所有机密信息仅存储在GitHub Actions的秘密管理功能中
-
这些信息永远不会出现在代码仓库中
-
它们也不会出现在任何拉取请求中
-
它们更不会出现在Git的历史记录中
环境隔离:
每个环境都会生成自己的配置文件:
开发环境:使用开发环境的API地址测试环境:使用测试环境的API地址生产环境:使用生产环境的API地址
如果没有在Dart中使用分支逻辑,同一个代码库在不同环境下的行为也会不同。
构建过程的确定性:
每一次构建都可以被完全复现,整个构建过程都是自动化的,并且会明确指出当前是在哪个环境中进行构建的。
因此,“在本地可以正常运行”这种情况是不存在的。
5. 验证必需的参数
if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
echo "使用方法:$0 "
exit 2
fi
这段代码用于确保参数的使用方式是正确的。
-
-z用于检查某个变量是否为空 -
如果缺少任何必需的参数:
-
系统会输出一条提示信息
-
脚本会以非零状态码终止执行
-
-
0:表示成功 -
1+:表示失败 -
2:通常表示使用方式不正确
在持续集成环境中,这种情况会立即导致任务失败,从而避免生成无效的构建结果。
6. 注入环境变量值
sed -e "s|<<BASE_URL>>|$BASE_URL|g" \
-e "s|<<ENCRYPTION_KEY>>|$ENCRYPTION_KEY|g" \
-e "s|<<ENV_NAME>>|$ENV_NAME|g" \
"(TEMPLATE" > "\)OUT"
这就是这个脚本的核心部分。
这里发生了什么:
-
sed执行的是流式编辑:它读取文本,对其进行处理,然后输出结果 -
每个
-e选项都定义了一条替换规则:-
将
<<BASE_URL>>替换为实际的API地址 -
将
<<ENCRYPTION_KEY>>替换为真实的加密密钥 -
将
<<ENV_NAME>>替换为环境变量的名称
-
-
处理后的结果会被写入文件
env_ci.g.dart中
整个操作都是在构建阶段进行的:
-
没有任何敏感信息会被存储起来
-
也没有任何敏感信息会被记录到日志中
-
这些敏感信息在持续集成流程结束后就会被清除
7. 成功反馈
echo "已为 $ENV_NAME 生成配置文件"
这条日志会在持续集成系统中明确提示操作成功。
它能够立即回答三个重要的问题:
-
脚本是否已经运行完毕?
-
脚本是否成功执行完成了?
-
生成了哪个环境配置文件?
在冗长的持续集成日志中,这样的确认信息非常重要。
好了,现在我们来看第二个脚本吧。
脚本#2:`quality_gate.sh`
这个脚本定义了“优质代码”对你们团队来说意味着什么。
#!/usr/bin/env bash
set -euo pipefail
echo "正在执行质量检查"
dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test --no-pub --coverage
if command -v dart_code_metrics >/dev/null 2>&1; then
dart_code_metrics analyze lib --reporter=console || true
fi
echo "质量检查通过"
让我们逐步分析这个脚本。
1. 开始与结束日志标记
echo "正在执行质量检查"
...
echo "质量检查通过"
这两行代码在持续集成日志中起到了可视化分隔线的作用。
在大型管道中(尤其是当Android和iOS的测试任务同时运行时),日志信息可能会非常杂乱。设置明确的标记非常重要:
-
这有助于开发人员快速找到出现问题的环节
-
从而加快调试速度
-
确保脚本已成功执行完毕
只有当上述所有步骤都通过时,才会显示最终的成功提示信息,因为如果出现错误,set -e命令会立即终止脚本的执行。
因此,这条指令实际上意味着:所有的质量检查都已通过,可以继续下一步操作了。
2. 运行测试套件
flutter test --no-pub --coverage
这条指令会执行你所有的Flutter测试任务。
让我们仔细分析一下它的具体作用。
1. flutter test
这个命令会运行单元测试、组件测试,以及test/目录下的所有测试。如果有任何测试失败,命令会以非零状态码终止执行。
由于我们之前已经设置了set -e,因此一旦有测试失败,脚本就会立即停止运行,从而导致CI任务失败。
2. --coverage
这个选项会生成代码覆盖率报告,文件路径为:
coverage/lcov.info
之后可以将这份报告上传到Codecov平台,用于检查代码的最低覆盖率要求,并通过长期跟踪来评估代码质量的改进情况。
即使你现在还没有强制要求必须生成代码覆盖率报告,现在进行这项操作也能为你的测试流程带来长远的好处。
3. 可选的代码指标分析
if command -v dart_code_metrics >/dev/null 2>&1; then
dart_code_metrics analyze lib --reporter=console || true
fi
这段代码是可选的,并且不会阻塞整个脚本的执行流程。
第一步 – 检查该工具是否已安装:
command -v dart_code_metrics >/dev/null 2>&1
这个命令用于检测dart_code_metrics工具是否已经安装在系统中。
-
如果已安装,继续执行后续步骤
-
如果没有安装,则直接跳过这一部分
这里使用了重定向操作:
>/dev/null用于隐藏正常的输出信息2>&1用于隐藏错误信息-
即使没有安装该工具,开发人员仍然可以正常运行这个脚本
-
如果配置了相应的检查机制,CI系统也会强制执行这一步骤
这样的设计使得脚本具有更好的兼容性:
第二步 – 运行代码指标分析(非强制要求):
dart_code_metrics analyze lib --reporter=console || true
这个命令会分析lib/目录中的代码,并将分析结果输出到控制台。
关键在于这里的|| true部分:
由于我们设置了set -e,因此任何失败的命令都会导致脚本终止。但添加了|| true之后,即使有命令失败,脚本也会继续执行下去。
-
如果指标检测出异常问题,
-
脚本仍会继续执行,
-
因此持续集成流程也不会失败。
为什么要这样设计呢?因为这些指标通常代表着逐步的改进、技术债务的体现,或者只是起到提示作用,并不会产生阻碍。
之后你可以删除 || true 这一行代码,从而使这些指标成为必填项。
4. 最终成功提示信息
echo "✅ 质量检查通过"
只有当格式化检查、静态分析以及测试都通过后,这一行代码才会被执行。
如果在持续集成日志中看到这条消息,那就说明该分支已经成功通过了质量检测。在部署步骤开始之前,这实际上是一种自动化的批准机制。
这个脚本能确保什么
通过设置这样的规则,每个分支都必须满足以下条件:
-
格式化正确
-
分析过程中没有出现错误
-
测试结果合格
-
(可选)各项指标正常
这样,我们就从“努力保持质量”转变为“质量得到自动保障”了。
好了,接下来来看第三个脚本。
脚本 #3: upload_symbols.sh (用于Sentry系统)
这个脚本负责将混淆后的调试符号上传到Sentry系统中,这样在生产环境中发生的崩溃信息就能被清晰地查看到了。
#!/usr/bin/env bash
set -euo pipefail
RELEASE=${1:-}
[ -z "$RELEASE" ] && exit 2
if ! command -v sentry-cli >/dev/null 2>&&1; then
exit 0
fi
sentry-cli releases new "$RELEASE" || true
sentry-cli upload-dif build/symbols || true
sentry-cli releases finalize "$RELEASE" || true
echo "✅ 已为版本 $RELEASE 上传了调试符号"
让我们一步步来看这个脚本的运行流程。
1. 读取版本标识符
RELEASE=${1:-}
这一行代码用于读取传递给脚本的第一个参数。
在持续集成环境中调用这个脚本时,通常会这样写:
./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
因此,$1变量中存储的就是Git提交的简短SHA值。
使用${1:-}这种写法可以确保:
-
如果没有传递任何参数,这个变量的值就会是空字符串
-
由于使用了
set -u命令,脚本也不会因此出错而崩溃
通过这种方式,上传的调试符号、部署后的构建产物以及崩溃报告都能被关联到同一个Git提交版本上。这种关联对于生产环境中的故障排查来说至关重要。
2. 验证版本参数的有效性
[ -z "$RELEASE" ] && exit 2
这是一个简单的验证步骤。
-
-z用于检查字符串是否为空 -
如果字符串为空,就会以状态码2退出脚本执行
按照常规做法:
-
0表示成功 -
1+表示失败 -
2表示使用方法不正确
这样就可以防止在没有版本标识符的情况下执行符号上传操作,因为这种情况下Sentry的追踪功能将会失效。
3. 检查sentry-cli是否存在
if ! command -v sentry-cli >/dev/null 2>&1; then
exit 0
fi
这段代码用于检查环境中是否安装了sentry-cli工具。
其工作原理如下:
-
command -v sentry-cli用于检测该工具是否存在 -
>/dev/null 2>&1用于抑制所有输出信息 -
!用于对判断结果进行取反
因此,整段代码的含义是:“如果sentry-cli未安装,则程序正常退出。”
为什么使用0表示成功而不是失败呢?
因为并不是所有环境都需要执行符号上传操作。此外,在开发环境中可能并不会安装Sentry,因此你也不希望仅仅因为Sentry未配置而导致持续集成流程失败。
这种设计使得符号上传功能具有环境适应性,并且是可选的。
生产环境可以安装sentry-cli,而开发环境则可以直接跳过这一步骤。
4. 在Sentry中创建新版本
sentry-cli releases new "$RELEASE" || true
这段代码用于告诉Sentry:“存在一个版本标识为$RELEASE的新版本。”
即使该版本已经存在,脚本也会继续执行,因为:
|| true
这样就可以避免在以下情况下导致构建过程失败:
-
如果该版本已经创建过
-
如果命令执行过程中出现了非致命性错误
我们的目标是要保证系统的灵活性,而不是严格执行某些规则。
5. 上传调试信息文件
sentry-cli upload-dif build/symbols || true
这是整个流程中的核心步骤。
build/symbols文件是在使用以下命令构建Flutter时生成的:
--obfuscate --split-debug-info=build/symbols
当对Flutter构建结果进行混淆处理时:
-
方法名称会被重新命名
-
堆栈追踪信息会变得无法阅读
通过生成这些符号文件,Sentry才能将混淆后的堆栈追踪信息还原为可读的形式,并生成相应的崩溃报告。
如果没有这个步骤,生产环境中的崩溃信息将会显示为:
a.b.c.d (Unknown Source)
而经过这个步骤处理后,崩溃信息就会显示为:
AuthRepository.login()同样,
|| true这一逻辑确保了在以下情况下构建过程不会失败:
如果目标目录不存在
如果没有生成任何符号文件
上传过程中出现了临时性故障
符号文件的上传过程不应妨碍部署的进行。
6. 完成发布的最终步骤
sentry-cli releases finalize "$RELEASE" || true
这一操作标志着在Sentry系统中,发布流程已经完成。
发布流程完成后会触发以下信号:
-
发布版本已成功部署
-
系统可以开始收集崩溃报告了
-
现在可以进行生产环境监控了
与之前的步骤一样,这里使用了|| true这一逻辑来确保持续集成流程的稳定性。
这个脚本能保证什么
当所有配置都正确时:
-
生产环境的构建代码会被混淆处理
-
会生成调试符号文件
-
符号文件会被上传到Sentry系统中
-
崩溃报告能够与实际的源代码对应起来
-
发布的版本号会与提交代码的SHA值一致
这样,我们就实现了具备生产环境级别的崩溃监控功能。
现在我们已经了解了为优化这一流程而编写的三个辅助脚本,接下来让我们来看看即将创建的三个工作流配置文件吧。
工作流#1:PR_CHECKS.YML
这个工作流的目的是确保当某个Pull Request被提交到特定的基础分支时,所有质量检查都能通过。这样,在代码合并到基础分支之前,就能确保其质量符合要求。
本质上来说,这个工作流就像是一道“关卡”,用于验证即将被合并到基础分支的代码的质量。如果你的持续集成流程允许未经验证的代码进入基础分支,那么这样的流程就只是形式上的,而没有任何实际的保护作用。
让我们详细了解一下每次Pull Request检查时需要执行的具体操作。
1. 依赖关系的完整性检查
对于使用pub get命令来管理依赖关系的Flutter应用程序来说,确认所有依赖关系的完整性是非常重要的——这些依赖关系不仅必须是最新的,而且还需要确保它们之间是兼容的。
每个Pull Request在开始时都应该执行以下操作:
flutter pub get
这样就能确保:
-
《pubspec.yaml》文件格式正确
-
依赖关系的配置要求是一致的
- 锁定文件没有损坏
- 项目可以在干净的环境中正常构建
如果这些检查失败,那么该分支就无法被部署到生产环境中。
2. 静态分析
静态分析有助于保证代码的质量和架构的稳定性。它能够帮助发现诸如遗漏的`await`语句、无用的代码、空指针安全问题以及异步编程中的错误用法等问题。
大多数生产环境中的错误其实并不是业务逻辑上的问题,而是由于编码时疏忽造成的。通过静态分析,可以自动检查代码的一致性,这样代码审查就能更多地关注代码的实际意图,而不仅仅是语法错误。
flutter analyze --fatal-infos --fatal-warnings
3. 格式化
此命令可确保您的代码符合所在组织的编码规范与政策要求。
dart format --output=none --set-exit-if-changed。
4. 测试
通过运行单元测试、组件测试以及业务逻辑测试,可以确保代码质量,避免出现回归问题、隐藏的行为变化或功能偏差。
flutter test --coverage
5. 测试覆盖率要求
理想情况下,仅仅运行测试是不够的。您的工作流程还应当设定最低覆盖率标准:
if [ \((lcov --summary coverage/lcov.info | grep lines | awk '{print \)2}' | sed 's/%//') -lt 70 ]; then
echo "覆盖率过低"
exit 1
fi
上述命令可确保测试覆盖率达到至少70%的标准,从而使代码质量得以有效衡量。
为了保证代码的质量、安全性与完整性,至少需要执行上述五个命令来进行质量检查。
以下是完整的pr_checks.yml文件内容:
name: PR质量检查
on:
pull_request:
branches: develop
types: [opened, synchronize, reopened, ready_for_review]
jobs:
pr-checks:
name: 对此拉取请求进行质量检查
runs-on: ubuntu-latest
steps:
- name: 检出代码
uses: actions/checkout@v2
- name: 安装Java开发环境
uses: actions/setup-java@v1
with:
java-version: "12.x"
- name> 安装Flutter开发环境
uses: subosito/flutter-action@v1
with:
channel: "stable"
- name: 安装依赖项
run: flutter pub get
- name: 运行质量检查
run: ./scripts/quality_checks.sh
- name> 通知团队(测试通过)
if: success()
run: |
echo "PR质量检查通过"
echo "拉取请求链接:${{ github.event.pull_request.html_url }}"
echo "分支信息:\({{ github.head_ref }} → \){{ github.base_ref }}"
echo "执行者:@${{ github.actor }}
echo "团队通知邮箱:@foluwaseyi-dev @olabodegbolu"
- name> 通知团队(测试失败)
if: failure()
run: |
echo "PR质量检查失败"
echo "拉取请求链接:${{ github.event.pull_request.html_url }}"
echo "分支信息:\({{ github.head_ref }} → \){{ github.base_ref }}"
echo "执行者:@${{ github.actor }}
echo "请先修复问题后再提交审核 🔧"
echo "团队通知邮箱:@foluwaseyi-dev @olabodegbolu"
每当有开发人员针对develop分支提交或更新拉取请求时,此工作流程都会自动启动。可以将其想象成门口的安检员——任何代码在未经检查之前都无法通过。
是什么触发了这个流程呢?
该工作流程会在四种情况下被触发:当一个Pull Request被打开、同步(有新的提交被推送)、重新打开,或者被标记为准备审核时。因此,草稿版本不会触发这个流程——只有那些真正准备好接受审核的Pull Request才会被处理。
它到底能做什么?
它会启动一台全新的Ubuntu机器,并按顺序执行以下五个步骤:
-
检出代码:下载相应分支的代码
-
安装Java 12:安装JDK(这可能是某些工具或构建流程所必需的依赖项)
-
配置Flutter环境:由于这是一个Flutter项目,因此会下载稳定的Flutter SDK
-
安装依赖库:运行
flutter pub get命令来下载所有Dart/Flutter相关包 -
执行质量检查:运行我们预先编写好的辅助脚本
./scripts/quality_checks.sh,该脚本会进行代码检查、测试以及格式验证等操作
通知机制
在所有检查完成后,工作流程会生成相应的报告,并且这个报告会根据具体的环境情况来显示结果:
-
如果所有检查都通过,系统会记录一条成功信息,其中会包含Pull Request的URL、分支名称以及提交该请求的用户信息
-
如果有任何检查未通过,系统会生成失败提示,并提醒作者在请求审核之前先修复相关问题
无论结果如何,系统都会通知两位团队成员:@foluwaseyi-dev和@olabodegbolu》——他们负责跟进这些处理结果。
这种工作流程体现了“在合并代码之前必须先修复其中的问题”这一原则。任何人都不能将有缺陷的代码合并到develop分支中,否则团队成员会立即察觉到这个问题。
工作流程#2:Android.yml
根据不同的平台来划分工作流程是一种更好的做法。这样做有助于更有效地管理针对各个平台的操作指令。正是出于这个原因,我们才将Android相关的工作流程单独列出来。
与PR _Checks不同,这个工作流程假定所有的质量检测和标准验证都已经完成,因此执行该流程的代码已经符合所有要求的标准。
根据我们预先定义的使用场景,我们来创建一个工作流程:当代码被合并到develop或staging分支时,这个流程会负责处理测试相关的部署工作;而当代码被合并到production分支时,则会执行与生产环境相关的一系列操作。
name: Android构建与发布
on:
push:
branches:
- develop
- staging
- production
jobs:
android:
runs-on: ubuntu-latest
env:
FLUTTER_VERSION: 'stable'
steps:
- name: 检出代码
uses: actions/checkout@v3
- name: 安装Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '11'
- name: 配置Flutter环境
uses: subosito/flutter-action@v2
with:
flutter-Version: ${{ env.FLUTTER_VERSION }}
- name: 安装依赖库
run: flutter pub get
- name: 确定当前环境
id: env
run: |
echo "branch=\({GITHUB_REF##*/}" > >> \)GITHUB_OUTPUT
if [ "${GITHUB_REF##*/}" = "develop" ]; then
echo "ENV=dev" > >> $GITHUB_OUTPUT
elif [ "${GITHUB_REF##*/}" = "staging" ]; then
echo "ENV=staging" > >> $GITHUB_OUTPUT
else
echo "ENV=production" > >> $GITHUB_OUTPUT
fi
# 开发环境使用固定值,无需配置密钥
- name: 生成配置文件(开发环境)
if: steps.envoutputs.ENV == 'dev'
run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"
# 测试和生产环境需要注入真实密钥
- name: 生成配置文件(测试/生产环境)
if: steps.env.outputs.ENV != 'dev'
run: |
if [ "${{ steps.envoutputs.ENV }}" = "staging" ]; then
./scripts/generate_config.sh staging \
"${{ secrets.STAGING_BASE_URL }}" \
"${{ secrets.STAGING_API_KEY }}"
else
./scripts/generate_config.sh production \
"${{ secrets.PROD_BASE_URL }}" \
"${{ secrets.PROD_API_KEY }}"
fi
# 只有测试和生产环境需要密钥库文件
- name: 恢复密钥库
if: steps.env.outputs.ENV != 'dev'
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-keystore.jks
# 生产环境的构建文件需要经过混淆处理,并且调试信息会被分离出来
- name: 构建应用程序包
run: |
if [ "${{ steps.env.outputs.ENV }}" = "production" ]; then
flutter build appbundle --release \
--obfuscate \
--split-debug-info=build/symbols
else
flutter build appbundle --release
fi
# 开发环境和测试环境会将构建文件上传到Firebase App Distribution进行内部测试
- name: 上传到Firebase App Distribution
if: steps.env.outputs.ENV == 'dev' || steps.envoutputs.ENV == 'staging'
env:
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
FIREBASE_ANDROID_APP_ID: ${{ secrets.FIREBASEANDROID_APP_ID }}
FIREBASE_groups: ${{ secrets.FIREBASE_GROUPS }}
run: |
firebase appdistribution:distribute \
build/app/outputs/bundle/release/app-release.aab \
--app "$FIREBASE-android_APP_ID" \
--groups "$FIREBASE-groups" \
--token "$FIREBASE_TOKEN"
# 只有生产环境需要上传到Play Store
- name: 上传到Play Store
if: steps.env.outputs.ENV == 'production'
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.your.package
releaseFiles: build/app/outputs/bundle/release/app-release.aab
track: production
- name: 通知团队(成功)
if: success()
run: |
echo "Android构建与发布流程成功完成"
echo "当前环境:${{ steps.env.outputs.ENV }}"
echo "分支名称:${{ steps.envoutputs.branch }}"
echo "执行者:@${{ github.actor }}"
echo "提交信息:${{ github.sha }}"
- name: 通知团队(失败)
if: failure()
run: |
echo "Android构建与发布流程失败"
echo "当前环境:${{ steps.env.outputs.ENV }}"
echo "分支名称:${{ steps.envoutputs.branch }}"
echo "执行者:@${{ github.actor }}"
echo "提交信息:${{ github.sha }}"
echo "请查看日志并修复问题后再重试"
这种工作流程能够确保:每当代码被推送到develop、staging或production分支时,就会在全新的Ubuntu机器上执行相应的操作。
这一过程只需简单地向任何被跟踪的分支进行推送操作,无需人工干预即可完成。
让我们一步步来详细了解这个流程。
1. 设置阶段
在开始进行与Flutter相关的开发工作之前,这个工作流程会先为后续步骤奠定基础:
-
代码获取:从触发构建操作的分支中下载最新的代码(使用更为现代化的
actions/checkout@v3命令)。 -
安装Java 11:这是对我们之前创建的工作流程的改进。此时不再使用通用的
setup-java@v1脚本,而是采用temurin发行版——这是Eclipse开源JDK的构建版本,也是当前Android开发工具链的行业标准。 -
获取Flutter稳定版本:会下载Flutter的稳定版本SDK,其版本号通过作业级别定义的环境变量
FLUTTER_VERSION: 'stable'来指定。 -
安装依赖项
:确保执行
flutter pub get命令以下载所有必要的软件包。
2. 环境检测
这一阶段非常关键。该工作流程会检测当前所处的环境,从而帮助我们确定接下来应该执行哪些操作。
系统会从GITHUB REF中读取分支名称,并将其与我们之前在辅助脚本中定义的环境标签对应起来。
-
develop → ENV=dev
-
staging → ENV=staging
-
production → ENV=production
系统会使用\({GITHUB_REF##*/}这个表达式从完整的引用路径中提取分支名称,然后将分支名称以及对应的ENV值写入\)GITHUB_OUTPUT文件中,这样后续的步骤就可以通过这些命名输出变量来获取相应的环境配置。
这意味着管道中的其他环节可以根据当前所处的环境来调整自身的行为——比如使用不同的API密钥、签名配置或目标版本,以满足应用程序的具体需求。
3. 配置注入
在确定了当前环境之后,下一步就是将正确的配置信息注入到应用程序中。这时之前编写的generate_config.sh脚本就会被直接调用。
对于dev环境,系统中会使用硬编码的占位符值;由于这种构建版本仅用于内部开发测试,因此不需要使用真实的敏感信息:
- name: 生成配置文件(dev环境)
if: steps.env.outputs.ENV == 'dev'
run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"
而对于staging和production环境,系统会从GitHub Actions的秘密存储库中获取真实的敏感信息,并将其直接传递给generate_config.sh脚本。
- name: 生成配置文件(测试环境/生产环境)
if: steps.env.outputs.ENV != 'dev'
run: |
if [ "${{ steps.envoutputs.ENV }}" = "staging" ]; then
./scripts/generate_config.sh staging \
"${{ secrets.STAGING_BASE_URL }}" \
"${{ secrets.STAGING_API_KEY }}"
else
./scripts/generate_config.sh production \
"${{ secrets.PROD_base_URL }}" \
"${{ secrets.PROD_API_KEY }}"
fi
需要注意的是,这两个步骤都使用了if条件来确保它们不会同时执行。每个任务中只会运行其中一个步骤,这样就能保持工作流程的简洁性:脚本本身不需要包含复杂的分支逻辑,只需在工作流层面进行明确的判断即可。
4. 密钥库的恢复
Android要求发布的应用程序必须经过签名处理。出于安全考虑,签名所使用的密钥库文件不能被存入代码仓库中,因此它会被以Base64编码的形式保存在GitHub的秘密配置中,在构建应用程序时再进行解码。
- name: 恢复密钥库
if: steps.envoutputs.ENV != 'dev'
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-keystore.jks
对于dev环境来说,这个步骤完全可以被跳过,因为开发版本的应用程序是未签名的调试版本,仅用于在Firebase App Distribution平台上进行内部测试。只有测试环境和生产环境构建出来的应用程序才需要经过正确的签名处理。
如果想要将密钥库文件编码成Base64字符串并保存在GitHub的秘密配置中,你可以在本地执行以下命令:
base64 -i upload-keystore.jks | pbcopy
这个命令会将编码后的字符串复制到剪贴板中,你可以将其直接粘贴到GitHub仓库的秘密配置中。
5. 构建最终应用程序文件
在环境配置完成且密钥库文件准备就绪之后,工作流程会开始构建应用程序包:
- name: 构建最终应用程序文件
run: |
if [ "${{ steps.env.outputs.ENV }}" = "production" ]; then
flutter build appbundle --release \
--obfuscate \
--split-debug-info=build/symbols
else
flutter build appbundle --release
fi
生产版本与非生产版本的构建过程存在明显的区别。
对于生产版本来说:
-
--obfuscate选项会修改编译后生成文件中的方法名和类名,这使得逆向工程应用程序变得非常困难。 -
选项会将调试符号提取到build/symbols目录中。
这些调试符号之后会被upload_symbols.sh脚本发送到Sentry平台,因此即使报告出现了混淆,你的监控面板仍然能够正常显示这些信息。
而对于开发环境和测试环境来说,这两个选项都不会被使用。这样既能加快构建速度,也能方便进行本地调试,因为堆栈追踪信息仍然是人类可读的格式。
6. 将应用发布到 Firebase App Distribution
一旦应用程序包构建完成,开发版本和测试版本就会被上传到 Firebase App Distribution,这样测试人员就可以立即安装这些版本:
- name: 上传到 Firebase App Distribution
if: steps.env.outputs.ENV == 'dev' || steps.envoutputs.ENV == 'staging'
env:
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
FIREBASE_ANDROID_APP_ID: ${{ secrets.FIREBASEANDROID_APP_ID }}
FIREBASE_groups: ${{ secrets.FIREBASE.groups }}
run: |
firebase appdistribution:distribute \
build/app/outputs/bundle/release/app-release.aab \
--app "$FIREBASE_AndROID_APP_ID" \
--groups "$FIREBASE Groups" \
--token "$FIREBASE_TOKEN"
这个步骤需要使用三个秘钥:
-
FIREBASE_TOKEN:通过firebase login:ci生成的认证令牌 -
FIREBASEANDROID_APP_ID:在 Firebase 控制台中设置的应用程序标识符 -
FIREBASE_groups:应该接收构建通知的测试人员所在的组别
完成这个步骤后,指定组别中的所有测试人员都会收到一封包含直接下载链接的电子邮件。因此,没有人需要通过 Slack 或电子邮件手动分享 APK 文件。
7. 将应用发布到 Google Play 商店
生产版本的构建过程会跳过 Firebase,直接上传到 Google Play 商店:
- name: 上传到 Google Play 商店
if: steps.env.outputs.ENV == 'production'
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.your.package
releaseFiles: build/app/outputs/bundle/release/app-release.aab
track: production
这个步骤使用了 r0adkll/upload-google-play 这个 GitHub Action,它负责处理与 Google Play API 的交互。使用这个动作只需要满足以下要求:
-
一个具有正确权限的 Google Play 服务账户,该账户的信息以 JSON 格式存储为秘钥
-
应用程序包名称必须与你在 Google Play 控制台中注册的名称一致
-
将
track参数设置为production(根据你的发布策略,也可以使用internal、alpha或beta)
请将 com.your.package 替换为你的实际应用程序 ID(也就是你在 build.gradle 文件中定义的那个 ID)。
8. 通知机制
与 PR 检查工作流程一样,这个工作流程也会清晰地报告其执行结果:
- name: 通知团队(成功)
if: success()
run: |
echo "Android 应用构建及发布成功"
echo "环境:${{ steps.env.outputs.ENV }}"
echo "分支:${{ steps.envoutputs.branch }}"
echo "执行者:@${{ github.actor }}
echo "提交信息:${{ github.sha }}"
- name: 通知团队(失败)
if: failure()
run: |
echo "Android 应用构建及发布失败"
echo "环境:${{ steps.env.outputs.ENV }}"
echo "分支:${{ steps.envoutputs.branch }}"
echo "执行者:@${{ github.actor }}
echo "提交信息:${{ github.sha }}"
echo "请查看日志并解决问题后再重试 🔧"
成功通知会包含相关环境信息、分支名称以及执行操作的用户信息,从而能够准确追踪到底是什么被部署了,以及是谁触发了这个部署过程。
失败通知也会提供相同的背景信息,并会明确提示需要采取哪些行动。
工作流程#3:iOS.yml
从本质上来说,iOS的CI/CD流程比Android的要复杂得多。这是因为苹果公司的签名要求涉及证书、配置文件以及权限设置等要素,这些内容都必须准备妥当,Xcode才能生成有效的部署包。
Fastlane帮助我们应对这些复杂性,而我们的工作流程只是简单地调用Fastlane的相关功能而已。
以下是完整的ios.yml文件内容:
name: iOS构建与发布
on:
push:
branches:
- develop
- staging
- production
jobs:
ios:
runs-on: macos-latest
env:
FLUTTER_VERSION: 'stable'
steps:
- name: 检出代码
uses: actions/checkout@v3
- name: 安装Flutter开发环境
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: 安装依赖库
run: flutter pub get
- name: 确定当前环境
id: env
run: |
echo "branch=\({GITHUB_REF##*/}" > >> \)GITHUB_OUTPUT
if [ "${GITHUB_REF##*/}" = "develop" ]; then
echo "ENV=dev" > >> $GITHUB_OUTPUT
elif [ "${GITHUB_REF##*/}" = "staging" ]; then
echo "ENV=staging" > >> $GITHUB_OUTPUT
else
echo "ENV=production" > >> $GITHUB_OUTPUT
fi
- name: 生成开发环境配置文件
if: steps.env.outputs.ENV == 'dev'
run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"
- name: 生成测试/生产环境配置文件
if: steps.envoutputs.ENV != 'dev'
run: |
if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
./scripts/generate_config.sh staging \
"${{ secrets.STAGING_BASE_URL }}" \
"${{ secrets.STAGING_API_KEY }}"
else
./scripts/generate_config.sh production \
"${{ secrets.PROD_base_URL }}" \
"${{ secrets.PROD_API_KEY }}"
fi
- name: 安装Fastlane工具
run: |
cd ios
gem install bundler
bundle install
- name: 导入签名证书
if: steps.env.outputs.ENV != 'dev'
run: |
echo "${{ secrets.IOS_CERTIFICATE_BASE64 }}" | base64 --decode > ios/cert.p12
security create-keychain -p "" build.keychain
security import ios/cert.p12 -k build.keychain -P "${{ secrets.IOS_CERTIFICATE_PASSWORD }}" -T /usr/bin/codesign
security list-keychains -s build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "" build.keychain
security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain
- name: 安装配置文件
if: steps.env.outputs.ENV != 'dev'
run: |
echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode > profile.mobileprovision
mkdir -p ~/Library/MobileDevice/Provisioning\Profiles
cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\Profiles/
- name: 构建开发环境下的iOS应用
if: steps.env.outputs.ENV == 'dev'
run: flutter build ios --release --no-codesign
- name: 在TestFlight平台上进行测试环境的构建与发布
if: steps.envoutputs.ENV == 'staging'
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_store_CONNECT_API_KEY_ID }}
APP STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APPSTORE_CONNECT_API ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE.CONNECT_API_KEY_CONTENT }}
run: |
cd ios
bundle exec fastlane beta
- name: 在App Store上进行生产环境的构建与发布
if: steps.env.outputs.ENV == 'production'
env:
APP STORE_CONNECT_API_KEY_ID: ${{ secrets.APPSTORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_store.CONNECT_API ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APPSTORECONNECT_API KEY CONTENT }}
run: |
cd ios
bundle exec fastlane release
- name: 上传Sentry符号文件(仅限生产环境)
if: steps.env.outputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_Project: ${{ secrets.SENTRYPROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 通知团队(成功情况)
if: success()
run: |
echo "iOS构建与发布成功"
echo "环境:${{ steps.env.outputs.ENV }}"
echo "分支:${{ steps.envoutputs.branch }}"
echo "执行者:@${{ github.actor }}"
echo "提交信息:${{ github.sha }}"
- name: 通知团队(失败情况)
if: failure()
run: |
echo "iOS构建与发布失败"
echo "环境:${{ steps.env.outputs.ENV }}"
echo "分支:${{ steps.envoutputs.branch }}"
echo "执行者:@${{ github.actor }}"
echo "提交信息:${{ github.sha }}"
echo "请查看日志并解决问题后再重试 🔧"
让我们来了解一下,这种工作流程与Android的工作流程有哪些不同之处。
1. MacOS Runner
runs-on: macos-latest
这就是主要的区别所在。
iOS版本的构建需要使用Xcode,而Xcode仅能在macOS系统上运行。GitHub Actions确实提供了托管的macOS运行环境,但与Ubuntu运行环境相比,其在计算资源消耗方面要高得多。因此在考虑构建频率时,请务必记住这一点。
在这里不需要进行任何Java相关的配置。在iOS平台上,Flutter是通过Xcode直接进行编译的,因此工具链的要求也有所不同。
2. 安装Fastlane
- name: 安装Fastlane
run: |
cd ios
gem install bundler
bundle install
Fastlane是一款基于Ruby的自动化工具,它可以负责处理证书管理、构建流程以及将应用程序上传到TestFlight和App Store。
这个步骤会进入ios/目录,并根据项目中定义的Gemfile文件内容,安装Fastlane及其所有依赖项。
你的ios/Gemfile文件应该类似于以下内容:
source "https://rubygems.org"
gem "fastlane"
而你的ios/fastlane/Fastfile文件至少需要定义两个构建流程:一个用于测试环境(TestFlight),另一个用于生产环境(App Store):
default_platform(:ios)
platform :ios do
lane :beta do
build_app(scheme: "Runner", export_method: "app-store")
upload_to_testflight(skip_waiting_for_build_processing: true)
end
lane :release do
build_app(scheme: "Runner", export_method: "app-store")
upload_to_app_store(force: true, skip_screenshots: true, skip_metadata: true)
end
end
3. 证书与配置文件的设置
这一步骤往往是很多团队在初次尝试时会遇到的问题。苹果公司的代码签名机制要求机器上必须具备以下两样东西:
-
签名证书(一个
.p12文件) -
配置文件
这两份文件都会以Base64编码的形式存储在GitHub的秘密信息系统中,并在构建过程中被重新加载。
- name: 导入签名证书
if: steps.env.outputs.ENV != 'dev'
run: |
echo "${{ secrets.IOS_CERTIFICATE_BASE64 }}" | base64 --decode > ios/cert.p12
security create-keychain -p "" build.keychain
security import ios/cert.p12 -k build.keychain -P "${{ secrets.IOS_CERTIFICATE_PASSWORD }}" -T /usr/bin/codesign
security list-keychains -s build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "" build.keychain
security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain
下面我们来详细说明每一步的具体操作:
-
解码Base64格式的证书,并将其保存到文件
cert.p12中。 -
创建一个名为
build.keychain的临时密钥链,为其设置空密码。 -
将该证书导入到这个临时密钥链中,从而使其具备代码签名功能。
-
将此临时密钥链设置为默认密钥链,这样Xcode就能自动找到它。
-
解锁这个临时密钥链,以便能够非交互式地使用它。
-
调整分区设置,以避免在每次使用时都需要重复输入密码。
- name: 安装配置文件
if: steps.envoutputs.ENV != 'dev'
run: |
echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode > profile.mobileprovision
mkdir -p ~/Library/MobileDevice/Provisioning\Profiles
cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\Profiles/
base64 -i Certificates.p12 | pbcopy # 用于证书
base64 -i YourApp.mobileprovision | pbcopy # 用于配置文件
4. 为每种环境构建代码
- name: 构建iOS版本(开发环境)
if: steps.envoutputs.ENV == 'dev'
run: flutter build ios --release --no-codesign
- name: 构建测试环境版本
if: steps.env.outputs.ENV == 'staging'
run: flutter build ios --release --no-codesign
- name: 构建正式发布版本
if: steps.envoutputs.ENV == 'production'
run: flutter build ios --release --no-codesign
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.env.outputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_Project }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
密钥与配置参考
- name: 安装配置文件
if: steps.envoutputs.ENV != 'dev'
run: |
echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode > profile.mobileprovision
mkdir -p ~/Library/MobileDevice/Provisioning\Profiles
cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\Profiles/
base64 -i Certificates.p12 | pbcopy # 用于证书
base64 -i YourApp.mobileprovision | pbcopy # 用于配置文件
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY PROJECT: ${{ secrets.SENTRYPROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY/ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY/ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY/ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY/ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY/ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY/ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY/ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY/ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY/ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY/ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY/ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
- name: 上传Sentry符号文件(仅限正式发布环境)
if: steps.envoutputs.ENV == 'production'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRYAUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY/ORG }}
SENTRYPROJECT: ${{ secrets.SENTRY PROJECT }}
run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
| 秘钥 | 描述 |
|---|---|
FIREBASE_TOKEN |
通过在本地计算机上使用firebase login:ci命令生成的 |
FIREBASE_ANDROID_APP_ID |
来自Firebase控制台的Android应用ID |
FIREBASE_groups |
在Firebase中用逗号分隔的测试组名称 |
SENTRY_AUTH_TOKEN |
来自Sentry账户设置的认证令牌 |
SENTRY_ORG |
你的Sentry组织名称 |
SENTRY_Project |
你的Sentry项目名称 |
测试环境:
| 保密信息 | 说明 |
|---|---|
STAGING_BASE_URL |
您的测试环境API基础URL |
STAGING_API_KEY |
您的测试环境API密钥或加密密钥 |
生产环境:
| 保密信息 | 说明 |
|---|---|
PROD_BASE_URL |
您的生产环境API基础URL |
PROD_API_KEY |
您的生产环境API密钥或加密密钥 |
Android环境:
| 保密信息 | 说明 |
|---|---|
ANDROID_KEYSTORE_BASE64 |
经过Base64编码的`.jks`密钥库文件 |
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON |
您的Play Console服务账户的完整JSON信息 |
iOS环境:
| 保密信息 | 说明 |
|---|---|
IOS_CERTIFICATE_BASE64 |
经过Base64编码的`.p12`签名证书文件 |
IOS_CERTIFICATE_PASSWORD |
用于保护`.p12`文件的密码 |
IOS_PROVISIONING_PROFILE_BASE64 |
经过Base64编码的`.mobileprovision`文件 |
APP_STORE_CONNECT_API_KEY_ID |
从App Store Connect → 用户与访问 → 密钥中获取的密钥ID |
APP_store_CONNECT_API_ISSUER_ID |
同样来自App Store Connect页面的发行者ID |
APP_STORE_CONNECT_API_KEY_CONTENT |
下载后的`.p8`密钥文件的完整内容 |
这些保密信息中的任何一项都不应该出现在您的代码库中。如果有任何保密信息被意外地包含到了代码中,应立即更换这些信息。
端到端流程
当这三套工作流程都正常运行时,从开发者提交拉取请求的那一刻起,到用户收到更新内容的这一整个过程如下:
1. 开发者向develop分支提交拉取请求
pr_checks.yml工作流程会被触发。它会执行格式检查、静态分析以及完整的测试套件测试。如果有任何测试失败,拉取请求将无法被合并,团队会立即收到通知。开发者会修复这些问题,然后再次提交代码,此时系统会重新运行所有的测试。
2. 拉取请求获得批准并被合并到develop分支
android.yml和ios.yml工作流程会在代码提交时被触发。它们会识别当前环境为dev,插入占位配置文件,生成未签名的构建文件,并将这些文件上传到Firebase App Distribution平台。测试人员会收到邮件,在几分钟内就能在他们的设备上安装这些更新后的应用程序——整个过程完全不需要人工共享文件。
3. develop分支被合并到staging分支中
此时,两个平台的工作流程都会再次被触发。这次,环境会设置为staging。真实的密钥会被注入到代码中,构建版本也会被正确签名,最终生成的文件会上传到Firebase App Distribution(用于Android应用)和TestFlight(用于iOS应用)系统中。质量保障团队会开始使用staging版本的API来测试这些构建版本。
4. staging分支被合并到production分支中
最后,这两个工作流程会再次被执行一次。这次会使用生产环境的密钥,构建版本会被加密并签名,调试符号也会上传到Sentry系统中,最终生成的文件会提交到Google Play商店和App Store Connect平台上。应用就会在苹果公司和谷歌公司的审核流程中正式上线,之后不再需要人工进行任何干预了。
从最初提交那个拉取请求开始,直到最终将应用发布到生产环境中,整个过程中没有一条命令是手动执行的。
结论
构建这样的开发流程其实是一项前期投资,但从第一个发布周期开始,这种投资就会带来回报。过去,那些容易出错的手动操作——比如在本地进行代码构建、签名、上传文件、切换配置设置,然后祈祷一切都不会出错——现在都已经被完全自动化了。这个过程是可审计的,也可以重复执行,只要代码在不同分支之间被移动,这些流程就会自动启动。
我们建立的这套架构不仅仅实现了构建过程的自动化。拉取请求的质量审核机制能够确保团队始终遵循统一的标准,因此代码审查实际上变成了对开发意图的讨论,而不是仅仅寻找格式错误的问题。环境感知型的配置注入功能有效地避免了那些由于staging环境的密钥被误用到生产环境中而导致的问题。而Sentry系统能够上传调试符号,这意味着即使面对经过加密的二进制文件,你的团队也能完全查看源代码来进行故障排查。
这个开发流程的每一个环节都可以在本地执行。scripts/文件夹中的辅助脚本都是用Bash语言编写的,因此你可以像使用持续集成工具一样,在终端中直接调用它们。这样就避免了那种为了测试某个流程变更而不得不先提交代码然后再拉取更新的繁琐过程。
随着你的团队规模不断扩大,这套基础架构也会随之扩展。你可以修改pr_checks.yml文件来设置代码覆盖率的最低要求,添加性能测试环节,或者引入专门的安全扫描步骤。你也可以根据需要调整平台的工作流程,以便支持多种不同的应用类型、多个Firebase项目,或者在Google Play商店上分阶段发布应用。整个架构本身是不变的,你只需要在已经正常运行的系统中添加新的步骤而已。
这样就能确保各项标准得到遵守,代码质量始终保持高水平,团队结构也会更加合理,开发后的各种流程也能自动化执行。归根结底,这种优化过的开发方式会在很多方面帮助你的团队取得更好的成果。

/filters:no_upscale()/news/2026/02/linkedin-redesigns-sast-pipeline/en/resources/1linkedsastcql-1769573478654.jpeg)

