关于 ci-pipeline-and-gates
为 Dart 和 Flutter 应用设计可审计 CI 流水线的技能,强调固定工具链、按契约设置门禁、本地复现检查,并说明 CI 无法验证的事项。
主要功能
- 01将每项门禁对应到命名契约
- 02固定 CI 运行器和工具链
- 03检查代码生成结果是否最新
- 04运行随机排序测试和静态门禁
- 05发布覆盖率报告但不设阈值
- 06确保 CI 检查不修改仓库
适用场景
- 01为单包 Flutter 应用搭建 CI 工作流
- 02发现过时代码生成或架构输出
- 03添加源码级不变量检查
- 04记录 CI 无法验证的发布事项
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill ci-pipeline-and-gates安装后在 Claude Code 中输入/ci-pipeline-and-gates确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
examples/policy_grep_test.dartreferences/policy-grep-gate.mdworkflow-skeleton.mdscripts/banned-strings.shci-gates.sh详细介绍
CI 流水线与门禁
CI 门禁不是装饰性的绿色勾号——它会将一项阻止发布的契约转化为构建必须通过或失败的检查,而无需信任人工判断。让流水线保持精简:大多数单包应用只需一个工作流文件;每个作业都固定版本;每个门禁都能追溯到一项具名契约;对于 CI 无法证明真正重要事项的地方,要如实说明。
请阅读与当前任务相关的参考文档:
references/workflow-skeleton.md— 可直接复制的单文件ci.yml:固定版本的运行器与工具链、新鲜度门禁、格式检查/分析、随机化测试、host-sqlite、coverage-strip,以及发布构建作业。references/policy-grep-gate.md— 基于 grep 的不变量门禁:三项标准门槛、先剥离注释、锚定到结构、汇总后一次性失败、为陌生人写明原因。
提交 PR 前,运行 scripts/ci-gates.sh 和 scripts/banned-strings.sh,在本地复现静态门禁。
不可妥协的规则
- 每个门禁都必须对应一个具名契约——请明确命名。 不服务于任何已声明契约的检查不算门禁:应将其设为建议项并明确说明,或者不要添加。绝不要凭空设置合并阻断项。这样能让流水线可审计,并防止门禁泛滥。
- 为每个作业固定运行器和工具链。 使用
runs-on: ubuntu-24.04(绝不使用-latest——镜像漂移会导致 lcov/工具链版本变化,却没有差异可供审查),并使用subosito/flutter-action@v2,明确指定flutter-version或flutter-version-file,以及channel: stable。浮动的 SDK 会悄无声息地重新生成 golden,并扰乱确定性。只能在专门的 PR 中升级版本。 - 未验证的 Action 版本要在行内标注;绝不要臆造主版本号。 错误的主版本号可能悄无声息地导致失败或改变行为。对于尚未确认的版本,请在旁边写上
# VERIFY:,不要猜测为@v7。 - 格式检查和分析必须是硬性门禁。 使用
dart format --output=none --set-exit-if-changed .和flutter analyze --fatal-infos。--fatal-infos至关重要:未修复的 info 会变成警告,之后就会被忽略。请参阅lint-and-style-config。 - 代码生成和架构是新鲜度门禁,不是构建步骤。 运行生成器,然后对输出执行
git diff --exit-code;差异非零时应失败,并提示作者重新生成并提交。这是防止提交生成代码的全部措施,也是唯一能发现“改了架构,却忘了迁移”的门禁。请参阅run-codegen、run-migration。 - 随机化测试顺序;为测试主机安装所需的原生依赖。
flutter test --test-randomize-ordering-seed random可以免费检测测试间状态泄漏。在 Linux 上,flutter test会在纯 Dart VM 中运行,sqlite3_flutter_libs不起作用——对于会打开真实数据库的测试套件,应先安装主机上的sqlite3,否则测试会失败,且原因看起来像是代码仓库出了问题。 - 静态 grep 能捕捉运行时无法发现的问题——必须满足三项标准。 基于 grep 的门禁只有在不变量可通过文本判定、出错时不会产生提示,且一行代码就能破坏它时才合理(
references/policy-grep-gate.md)。它能验证通过测试永远无法证明的源代码图属性(例如运行时未触达的导入、清单属性)。三项标准中只满足两项时,应交由代码审查,而非使用 grep。 - 覆盖率应作为已发布的报告,绝不能作为门禁。 不设百分比阈值,不使用付费服务。只覆盖却没有断言的代码毫无价值。首先要修正虚高的问题:
flutter test --coverage会忽略没有被任何测试导入的文件,因此未测试文件在分母中的行数是零,而不是 0%——只有一个文件经过测试,也可能报告约 100% 的覆盖率。纳入未测试文件后,再从lcov.info中剔除生成代码。请参阅testing-strategy。 - 门禁只做验证,绝不认可或修改代码仓库。 不得使用
--update-goldens、dart format --fix,也不得从 CI 提交重新生成的代码——修复所检查问题的门禁等于什么也没验证。重新生成应在本地进行,并由人工审查。Goldens 应在固定的运行器上运行,CI 只负责比较。请参阅widget-golden-and-a11y-testing。 - 门禁失败就阻断;如实说明其局限。 绝不在门禁上设置
continue-on-error: true,绝不在跳过门禁的情况下合并,也绝不对静态检查结果使用// ignore:。还要明确说明 CI 无法证明什么——例如音频输出、真实字体渲染、真实设备上的行为——这样,人工设备测试就会被视为发布流程中至关重要的交付物,而非琐事。
单工作流(单包默认配置)
对于大多数应用,一个文件 .github/workflows/ci.yml 就足够了。固定所有内容;将快速反馈集中在一个作业中。
name: ci
on:
push: { branches: [main] }
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04 # pinned, not -latest — rule 2
timeout-minutes: 20
steps:
- uses: actions/checkout@v4 # VERIFY: confirm current major
- uses: subosito/flutter-action@v2 # v2 is current; there is no v3
with:
channel: stable
flutter-version: '3.29.0' # pin, or flutter-version-file: .fvmrc
cache: true
- run: flutter pub get
- run: dart format --output=none --set-exit-if-changed .
- run: flutter analyze --fatal-infos
- run: flutter test --test-randomize-ordering-seed random --reporter expanded
完整骨架(包括新鲜度门禁、主机 sqlite、覆盖率剔除和发布构建作业)见 references/workflow-skeleton.md。
新鲜度门禁:运行生成器,并对比输出
这就是为什么提交 *.g.dart / *.freezed.dart / *.drift.dart 是安全的,以及如何发现未配套迁移的架构变更。
- name: Generated code is up to date
run: |
dart run build_runner build --delete-conflicting-outputs
if ! git diff --exit-code -- '*.g.dart' '*.freezed.dart' '*.drift.dart'; then
echo "::error::Generated code is stale. Run build_runner and commit the result."
exit 1
fi
- name: Schema dumps are up to date
run: |
dart run drift_dev schema dump lib/data/app_database.dart drift_schemas/
if ! git diff --exit-code -- drift_schemas/; then
echo "::error::drift_schemas/ is stale — you changed the schema without"
echo "::error::bumping schemaVersion and dumping. Shipping this means NO"
echo "::error::MIGRATION RUNS and existing rows fail to load."
exit 1
fi
编辑匹配模式前,先检查代码生成实际输出的后缀(现代版本用 .drift.dart,旧版分部文件用 .g.dart),不要猜。绝不要将新鲜度门禁弱化为警告,也不要只在标签上运行。
静态门禁:扫描源码图,一次性报错
有些约定无法通过任何测试发现——例如运行时不会触及的禁用导入,或某个配置属性。用 grep 检查它们。先去除注释(规则自身的说明可能就包含要查找的字符串),以结构为匹配锚点(导入 URI、XML 元素),收集所有违规项,并一次性报错,同时列出完整清单和让外人也能理解的原因。
// test/policy/banned_imports_test.dart — match the import URI, not the file.
import 'dart:io';
import 'package:test/test.dart';
void main() {
test('lib/ imports nothing that can reach the network or phone home', () {
const banned = <String>[
'package:http/', 'package:dio/',
'package:firebase_core/', 'package:sentry_flutter/',
];
final importUri =
RegExp(r'''^\s*import\s+['"]([^'"]+)['"]''', multiLine: true);
final lineComment = RegExp(r'//[^\n]*');
final offenders = <String>[];
for (final f in Directory('lib').listSync(recursive: true).whereType<File>()) {
if (!f.path.endsWith('.dart') || f.path.endsWith('.g.dart')) continue;
final code = f.readAsStringSync().replaceAll(lineComment, '');
for (final m in importUri.allMatches(code)) {
final uri = m.group(1)!;
if (banned.any(uri.startsWith)) offenders.add('${f.path}: $uri');
}
}
expect(offenders, isEmpty,
reason: 'These imports break the no-network promise.\n${offenders.join('\n')}');
});
}
编写这些规则的标准和注意事项见 references/policy-grep-gate.md;examples/policy_grep_test.dart 是一个完整文件。shell 门禁中的同类方法位于 scripts/banned-strings.sh,可在 Dart 测试套件之外用于 PR 前检查。
覆盖率:测量并发布,但不要设为门禁
引用任何数字前,先修正虚高问题,然后剔除生成代码。
# REQUIRED first: include untested files so the denominator is honest —
# a generated test that imports every lib/ file (see workflow-skeleton.md),
# otherwise one tested file can report ~100%. Collect coverage in the same
# randomized run; do not run the suite twice.
- run: flutter test --coverage --test-randomize-ordering-seed random
- name: Strip generated code from coverage
run: |
lcov --remove coverage/lcov.info \
'lib/**/*.g.dart' 'lib/**/*.freezed.dart' 'lib/**/*.drift.dart' \
-o coverage/lcov.info --ignore-errors unused # lcov 2.x errors on unused patterns
# Publish for transparency. Do NOT add a min-coverage threshold.
不要设置全局阈值,而是挑出少数几个具名文件,这些文件一旦出错便无法恢复(迁移、货币/单位核心逻辑、仓库写入路径),通过阅读差异来确保其覆盖率不低于底线——不要为了一个已知会说谎的指标而数行数。文件级底线的理由见 testing-strategy。
CI 中的 Golden 测试:验证,但绝不更新
如果在 CI 中运行 Golden 测试,应使用固定的运行器运行,并且只进行对比。CI 绝不能运行 --update-goldens——否则门禁就形同虚设。不同操作系统或字体导致的差异确实存在;在一台主机上生成的 Golden 测试在另一台主机上会失败。因此,要么固定运行器并以其结果为准,要么不在 CI 中运行 Golden 测试,改用布局不变的 Widget 测试,并在设备上进行测试。两路 Golden 测试策略见 widget-golden-and-a11y-testing。
多包场景(工作区)
仅适用于真正的 Dart pub 工作区 / Melos 单体仓库,不适用于单包应用:
- 在工作区根目录统一解析一次(在根目录运行
dart pub get会拉取所有成员);先在根目录运行代码生成,再运行 analyze(见run-codegen)。 - 按包边界限定禁用导入的依赖图检查范围(纯 Dart 核心不得导入
package:flutter);这是静态门禁唯一读取已解析依赖图、而不是源码文本的场景。 - 在一个
env:条目中固定flutter-version,并让每个任务都引用它,这样升级时只需改一处。
不要把这些做法说成小型应用也必需——单包项目不需要承担这些额外开销。
反模式
ubuntu-latest/ 浮动 SDK。 镜像和工具链会在工作流运行期间悄然变动,且没有差异可供审查,让绿色构建变成谎言。- 臆造 Action 主版本。 未经验证的
@v7可能悄然失效或改变行为;应标记# VERIFY:并确认。 - 在 CI 中自动认可。 使用
--update-goldens、让dart format写入修复,或提交重新生成的代码——这样一来,门禁就什么也没验证。 - 按覆盖率百分比设门禁。 这会奖励不做断言的测试;而且在纳入未测试文件之前,原始数字会虚高。
- 对整个文件执行
containsgrep。 这会命中规则自身的注释和字符串字面量——应先剥离注释并锚定到结构,否则就会误报,最后还被删掉。 - 在违规项循环中使用
expect。 这只会报告第 1 个违规项并隐藏其余项;应先累积结果,再统一失败。 - 在门禁上设置
continue-on-error: true、在门禁跳过时仍合并,或对静态检查结果使用// ignore:——被抑制的门禁恰恰就是它本来要捕获的漏洞。 - 更改架构却没有新鲜度门禁。 没有迁移就发布;现有数据会悄然无法加载。
- 声称 CI 变绿就能证明音频、真实字体渲染或设备端行为正常。 CI 做不到;应明确说明限制,并保留人工检查。
- 通过 grep 检查品味(例如禁止密封类型中的
default:,或禁止某个溢出修饰符)——误报会导致整个目录被删掉;这是代码审查的工作,不是门禁。
完成定义
- 每个作业都固定使用
runs-on: ubuntu-24.04(或明确指定的镜像)和subosito/flutter-action@v2,并显式指定flutter-version/flutter-version-file和channel: stable;未经验证的 Action 版本必须带有# VERIFY:。 - 运行
dart format --set-exit-if-changed和flutter analyze --fatal-infos,并由其阻止合并。 - 代码生成和(如适用)架构新鲜度检查都先运行生成器,再执行
git diff --exit-code;失败时给出可操作的提示。 - 测试使用
--test-randomize-ordering-seed random运行;任何原生宿主依赖项(例如sqlite3)都要在需要它的测试套件运行前安装。 - 每个门禁都对应一个具名契约;不得有未命名的合并阻断项;提示性检查应明确说明其仅供参考。
- 静态 grep(如有)应先剥离注释、锚定结构、累积结果后统一失败,并提供陌生读者也能看懂的理由。
- 生成覆盖率报告时,应修正虚高问题并剔除生成文件,再将报告发布出来——不得设置百分比门禁。
- 不得有任何门禁使用
continue-on-error、使用--update-goldens或修改仓库内容。 - 工作流应如实说明 CI 无法证明的事项;明确将设备端人工检查列为发布产物。
相关技能
run-codegen— 确定性的build_runner流程,新鲜度门禁会对其输出进行差异检查。run-migration— 架构快照流程,新鲜度门禁用于保护该流程。lint-and-style-config— analyze 门禁运行的analysis_options.yaml,以及对--fatal-infos的升级。testing-strategy— 覆盖率规则背后的文件级覆盖率下限和优先使用伪对象而非 Mock 的准则。widget-golden-and-a11y-testing— 双轨 Golden 策略,以及 CI 为何只做验证而绝不予以认可。codegen-and-toolchain— 门禁所引用的 SDK 固定、各软件包的build.yaml,以及分析器/覆盖率排除项。dependency-hygiene— 门禁所依赖的已提交锁文件和固定版本的 lint。design-review-workflow— 每个应用执行一次的人工 QA 检查,涵盖 CI 无法证明的事项。run-goldens-rebaseline— 仅在本地进行、由人工审查的认可流程,CI 绝不能执行。release-and-store-shipping— 门禁全部通过后的工作:签名产物、归档符号文件、商店声明和分阶段发布。
参考资料
- subosito/flutter-action — 为 GitHub Actions 配置 Flutter 环境。https://github.com/subosito/flutter-action
- Flutter — 构建并发布 Android 应用。https://docs.flutter.dev/deployment/android
- Flutter API —
matchesGoldenFile(golden 文件受操作系统、字体和版本影响;--update-goldens)。https://api.flutter.dev/flutter/flutter_test/matchesGoldenFile.html - Dart —
dart test运行器标志(--test-randomize-ordering-seed)。https://dart.dev/tools/dart-test - Code With Andrea — Flutter 测试覆盖率(
lcov.info、剔除生成文件、未测试文件造成的假象)。https://codewithandrea.com/articles/flutter-test-coverage/ - Dart — pub 工作区(单体仓库)。https://dart.dev/tools/pub/workspaces
- GitHub Actions — 使用并发控制、权限和固定的运行器。https://docs.github.com/actions