mcpField
C
ci-pipeline-and-gates

ci-pipeline-and-gates

github.com/zakariaf云与 DevOps63148 次安装5 周前更新
代码仓库

关于 ci-pipeline-and-gates

为 Dart 和 Flutter 应用设计可审计 CI 流水线的技能,强调固定工具链、按契约设置门禁、本地复现检查,并说明 CI 无法验证的事项。

主要功能

  1. 01将每项门禁对应到命名契约
  2. 02固定 CI 运行器和工具链
  3. 03检查代码生成结果是否最新
  4. 04运行随机排序测试和静态门禁
  5. 05发布覆盖率报告但不设阈值
  6. 06确保 CI 检查不修改仓库

适用场景

  1. 01为单包 Flutter 应用搭建 CI 工作流
  2. 02发现过时代码生成或架构输出
  3. 03添加源码级不变量检查
  4. 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.dart
references/
policy-grep-gate.md
workflow-skeleton.md
scripts/
banned-strings.sh
ci-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,在本地复现静态门禁。

不可妥协的规则

  1. 每个门禁都必须对应一个具名契约——请明确命名。 不服务于任何已声明契约的检查不算门禁:应将其设为建议项并明确说明,或者不要添加。绝不要凭空设置合并阻断项。这样能让流水线可审计,并防止门禁泛滥。
  2. 为每个作业固定运行器和工具链。 使用 runs-on: ubuntu-24.04(绝不使用 -latest——镜像漂移会导致 lcov/工具链版本变化,却没有差异可供审查),并使用 subosito/flutter-action@v2,明确指定 flutter-version 或 flutter-version-file,以及 channel: stable。浮动的 SDK 会悄无声息地重新生成 golden,并扰乱确定性。只能在专门的 PR 中升级版本。
  3. 未验证的 Action 版本要在行内标注;绝不要臆造主版本号。 错误的主版本号可能悄无声息地导致失败或改变行为。对于尚未确认的版本,请在旁边写上 # VERIFY:,不要猜测为 @v7。
  4. 格式检查和分析必须是硬性门禁。 使用 dart format --output=none --set-exit-if-changed . 和 flutter analyze --fatal-infos。--fatal-infos 至关重要:未修复的 info 会变成警告,之后就会被忽略。请参阅 lint-and-style-config。
  5. 代码生成和架构是新鲜度门禁,不是构建步骤。 运行生成器,然后对输出执行 git diff --exit-code;差异非零时应失败,并提示作者重新生成并提交。这是防止提交生成代码的全部措施,也是唯一能发现“改了架构,却忘了迁移”的门禁。请参阅 run-codegen、run-migration。
  6. 随机化测试顺序;为测试主机安装所需的原生依赖。 flutter test --test-randomize-ordering-seed random 可以免费检测测试间状态泄漏。在 Linux 上,flutter test 会在纯 Dart VM 中运行,sqlite3_flutter_libs 不起作用——对于会打开真实数据库的测试套件,应先安装主机上的 sqlite3,否则测试会失败,且原因看起来像是代码仓库出了问题。
  7. 静态 grep 能捕捉运行时无法发现的问题——必须满足三项标准。 基于 grep 的门禁只有在不变量可通过文本判定、出错时不会产生提示,且一行代码就能破坏它时才合理(references/policy-grep-gate.md)。它能验证通过测试永远无法证明的源代码图属性(例如运行时未触达的导入、清单属性)。三项标准中只满足两项时,应交由代码审查,而非使用 grep。
  8. 覆盖率应作为已发布的报告,绝不能作为门禁。 不设百分比阈值,不使用付费服务。只覆盖却没有断言的代码毫无价值。首先要修正虚高的问题:flutter test --coverage 会忽略没有被任何测试导入的文件,因此未测试文件在分母中的行数是零,而不是 0%——只有一个文件经过测试,也可能报告约 100% 的覆盖率。纳入未测试文件后,再从 lcov.info 中剔除生成代码。请参阅 testing-strategy。
  9. 门禁只做验证,绝不认可或修改代码仓库。 不得使用 --update-goldens、dart format --fix,也不得从 CI 提交重新生成的代码——修复所检查问题的门禁等于什么也没验证。重新生成应在本地进行,并由人工审查。Goldens 应在固定的运行器上运行,CI 只负责比较。请参阅 widget-golden-and-a11y-testing。
  10. 门禁失败就阻断;如实说明其局限。 绝不在门禁上设置 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 写入修复,或提交重新生成的代码——这样一来,门禁就什么也没验证。
  • 按覆盖率百分比设门禁。 这会奖励不做断言的测试;而且在纳入未测试文件之前,原始数字会虚高。
  • 对整个文件执行 contains grep。 这会命中规则自身的注释和字符串字面量——应先剥离注释并锚定到结构,否则就会误报,最后还被删掉。
  • 在违规项循环中使用 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 — 门禁全部通过后的工作:签名产物、归档符号文件、商店声明和分阶段发布。

参考资料

评论

支持 Markdown