From 0e92ab7dd9eb8b059e54483ab5c7ca13af4e0e78 Mon Sep 17 00:00:00 2001 From: nickshorebird Date: Fri, 15 May 2026 14:37:11 -0400 Subject: [PATCH] feat(shorebird_ci): codecov token plumbing + explicit fatal-warnings (#3772) --- packages/shorebird_ci/CHANGELOG.md | 5 + packages/shorebird_ci/README.md | 19 ++ .../shorebird_ci/example/shorebird_ci.yaml | 2 +- .../lib/src/commands/generate_command.dart | 121 +++++++-- packages/shorebird_ci/pubspec.yaml | 2 +- .../test/generate_command_test.dart | 253 ++++++++++++++++++ 6 files changed, 375 insertions(+), 27 deletions(-) diff --git a/packages/shorebird_ci/CHANGELOG.md b/packages/shorebird_ci/CHANGELOG.md index 07346e8a..64059b7e 100644 --- a/packages/shorebird_ci/CHANGELOG.md +++ b/packages/shorebird_ci/CHANGELOG.md @@ -1,5 +1,10 @@ +# 0.2.2 + +- Generated Dart workflows now emit `dart analyze --fatal-warnings .` explicitly. The SDK already defaults `--fatal-warnings` to on, so this is behavior-preserving today, but the explicit form documents intent and survives any future flip in the SDK default. Flutter workflows are unchanged: `flutter analyze` defaults `--fatal-warnings` to on with no CLI flag. +- New `--codecov-token-secret ` option on `generate`. When set, the static orchestrator emits `secrets: inherit` on each reusable workflow call, and the codecov-action step in the reusable workflows receives `token: ${{ secrets. }}`. The dynamic style threads the same `token:` into its single codecov-action step. When unset (the default), no token plumbing is emitted, matching prior behavior. Private repos that need a token to upload coverage should pass `--codecov-token-secret CODECOV_TOKEN` (or the secret name of their choice). + # 0.2.1 - Static main workflow YAML map keys default to each package's `name:` and fall back to `_` only when two or more packages share a name. Avoids prefixing in the common case while still handling duplicate-`name:` repos. The `package_name:` input passed to the reusable workflow keeps the actual package name for codecov flag display. diff --git a/packages/shorebird_ci/README.md b/packages/shorebird_ci/README.md index 8e9fa633..044e83f0 100644 --- a/packages/shorebird_ci/README.md +++ b/packages/shorebird_ci/README.md @@ -82,6 +82,25 @@ leaves the static pins in the template as-is. Use it in offline environments. Once you push, Dependabot picks up bumps on its weekly schedule. +### `--codecov-token-secret ` + +Pass the name of the GitHub Actions secret holding your Codecov +upload token. When set: + +- `--style static`: the orchestrator emits `secrets: inherit` on each + reusable workflow call, and the codecov-action step in the reusable + workflows receives `token: ${{ secrets. }}`. +- `--style dynamic` (default): the single codecov-action step receives + the same `token:` line. + +When unset (the default), no token plumbing is emitted. Whether you +need a token is a Codecov question — refer to their docs for whether +your repo requires one to upload. + +```bash +shorebird_ci generate --codecov-token-secret CODECOV_TOKEN +``` + ### `--style static` (advanced) `generate --style static` emits a pre-computed dorny `filters:` block, diff --git a/packages/shorebird_ci/example/shorebird_ci.yaml b/packages/shorebird_ci/example/shorebird_ci.yaml index 82fbc512..ccc30cba 100644 --- a/packages/shorebird_ci/example/shorebird_ci.yaml +++ b/packages/shorebird_ci/example/shorebird_ci.yaml @@ -58,7 +58,7 @@ jobs: dart pub get --no-example -C $sub done - run: dart format --set-exit-if-changed . - - run: dart analyze . + - run: dart analyze --fatal-warnings . - name: Bloc Lint if: matrix.has_bloc_lint == true run: bloc lint . diff --git a/packages/shorebird_ci/lib/src/commands/generate_command.dart b/packages/shorebird_ci/lib/src/commands/generate_command.dart index 0694ecac..a32283aa 100644 --- a/packages/shorebird_ci/lib/src/commands/generate_command.dart +++ b/packages/shorebird_ci/lib/src/commands/generate_command.dart @@ -46,6 +46,18 @@ class GenerateCommand extends Command with RepoRootOption { 'auto-bumps action versions to current latest. Pass this flag ' 'in offline environments.', negatable: false, + ) + ..addOption( + 'codecov-token-secret', + help: r''' +Name of the GitHub Actions secret holding the Codecov upload token. +When set, the codecov-action step receives +`token: ${{ secrets. }}` in both --style dynamic (default) and +--style static output. In --style static, the generated orchestrator +additionally emits `secrets: inherit` on each reusable workflow call +so the secret is reachable inside the reusable workflow. When unset +(the default), no token plumbing is emitted. Refer to Codecov's docs +for whether your repo requires a token to upload.''', ); } @@ -62,6 +74,7 @@ class GenerateCommand extends Command with RepoRootOption { final outputPath = argResults!['output'] as String; final dryRun = argResults!.flag('dry-run'); final style = argResults!['style'] as String; + final codecovTokenSecret = argResults!['codecov-token-secret'] as String?; final analyzer = RepositoryAnalyzer(); final repoDir = Directory(repoRoot); @@ -81,8 +94,17 @@ class GenerateCommand extends Command with RepoRootOption { // Action versions are emitted as the hardcoded defaults below; // run `shorebird_ci update_actions` to bump them. final files = style == 'static' - ? _buildStaticFiles(repository, outputPath: outputPath) - : {outputPath: _buildDynamicYaml(repository)}; + ? _buildStaticFiles( + repository, + outputPath: outputPath, + codecovTokenSecret: codecovTokenSecret, + ) + : { + outputPath: _buildDynamicYaml( + repository, + codecovTokenSecret: codecovTokenSecret, + ), + }; if (dryRun) { final sortedKeys = files.keys.toList()..sort(); @@ -174,6 +196,7 @@ updates: Map _buildStaticFiles( RepositoryDescription repository, { required String outputPath, + required String? codecovTokenSecret, }) { final packages = repository.packages.toList() ..sort( @@ -192,15 +215,22 @@ updates: outputPath: _buildStaticMainYaml( repository: repository, packages: packages, + codecovTokenSecret: codecovTokenSecret, ), }; if (hasDart) { files['.github/workflows/_shorebird_ci_dart.yaml'] = - _buildDartReusableWorkflow(hasCodecov: repository.hasCodecov); + _buildDartReusableWorkflow( + hasCodecov: repository.hasCodecov, + codecovTokenSecret: codecovTokenSecret, + ); } if (hasFlutter) { files['.github/workflows/_shorebird_ci_flutter.yaml'] = - _buildFlutterReusableWorkflow(hasCodecov: repository.hasCodecov); + _buildFlutterReusableWorkflow( + hasCodecov: repository.hasCodecov, + codecovTokenSecret: codecovTokenSecret, + ); } return files; } @@ -208,7 +238,12 @@ updates: String _buildStaticMainYaml({ required RepositoryDescription repository, required List packages, + required String? codecovTokenSecret, }) { + final emitSecretsInherit = + codecovTokenSecret != null && + codecovTokenSecret.isNotEmpty && + repository.hasCodecov; final resolver = DependencyResolver(repository.root.path); final slugs = computePackageSlugs( packages: packages, @@ -317,6 +352,10 @@ jobs: ); } + if (emitSecretsInherit) { + buffer.writeln(' secrets: inherit'); + } + buffer.writeln(); } @@ -327,22 +366,25 @@ jobs: return buffer.toString(); } - String _buildDartReusableWorkflow({required bool hasCodecov}) { + String _buildDartReusableWorkflow({ + required bool hasCodecov, + required String? codecovTokenSecret, + }) { final testStep = hasCodecov - ? r''' + ? ''' - name: Run Tests if: inputs.has_unit_tests - working-directory: ${{ inputs.package_path }} + working-directory: \${{ inputs.package_path }} run: | - dart pub global activate coverage && \ - dart test --coverage=coverage && \ + dart pub global activate coverage && \\ + dart test --coverage=coverage && \\ dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib --check-ignore - if: inputs.has_unit_tests uses: codecov/codecov-action@v5 with: - flags: ${{ inputs.package_name }} - working-directory: ${{ inputs.package_path }} -''' + flags: \${{ inputs.package_name }} + working-directory: \${{ inputs.package_path }} +${_codecovTokenLine(codecovTokenSecret)}''' : r''' - if: inputs.has_unit_tests working-directory: ${{ inputs.package_path }} @@ -395,7 +437,7 @@ jobs: - working-directory: \${{ inputs.package_path }} run: dart format --set-exit-if-changed . - working-directory: \${{ inputs.package_path }} - run: dart analyze . + run: dart analyze --fatal-warnings . - name: Bloc Lint if: inputs.has_bloc_lint working-directory: \${{ inputs.package_path }} @@ -403,18 +445,21 @@ jobs: $testStep'''; } - String _buildFlutterReusableWorkflow({required bool hasCodecov}) { + String _buildFlutterReusableWorkflow({ + required bool hasCodecov, + required String? codecovTokenSecret, + }) { final testStep = hasCodecov - ? r''' + ? ''' - if: inputs.has_unit_tests - working-directory: ${{ inputs.package_path }} + working-directory: \${{ inputs.package_path }} run: flutter test --coverage - if: inputs.has_unit_tests uses: codecov/codecov-action@v5 with: - flags: ${{ inputs.package_name }} - working-directory: ${{ inputs.package_path }} -''' + flags: \${{ inputs.package_name }} + working-directory: \${{ inputs.package_path }} +${_codecovTokenLine(codecovTokenSecret)}''' : r''' - if: inputs.has_unit_tests working-directory: ${{ inputs.package_path }} @@ -493,7 +538,10 @@ $testStep - name: Integration Tests // ── Dynamic (affected_packages + matrix) ───────────────────────── - String _buildDynamicYaml(RepositoryDescription repository) { + String _buildDynamicYaml( + RepositoryDescription repository, { + required String? codecovTokenSecret, + }) { final hasDart = repository.packages.any( (pkg) => !RepositoryAnalyzer.dependsOnFlutter(root: pkg.root), ); @@ -532,6 +580,7 @@ jobs: outputKey: 'dart_packages', sdk: 'dart', hasCodecov: repository.hasCodecov, + codecovTokenSecret: codecovTokenSecret, ); } @@ -542,6 +591,7 @@ jobs: outputKey: 'flutter_packages', sdk: 'flutter', hasCodecov: repository.hasCodecov, + codecovTokenSecret: codecovTokenSecret, ); } @@ -632,6 +682,7 @@ jobs: required String outputKey, required String sdk, required bool hasCodecov, + required String? codecovTokenSecret, }) { final isFlutter = sdk == 'flutter'; final executable = isFlutter ? 'flutter' : 'dart'; @@ -651,7 +702,9 @@ jobs: ); if (isFlutter) buffer.write(_integrationTestsStep); - if (hasCodecov) buffer.write(_codecovUploadStep); + if (hasCodecov) { + buffer.write(_codecovUploadStep(codecovTokenSecret: codecovTokenSecret)); + } buffer.writeln(); } @@ -709,9 +762,17 @@ jobs: } String _formatAndAnalyzeSteps({required String executable}) { + // `dart analyze` defaults `--fatal-warnings` to on, so passing it is a + // no-op today. Emitting it explicitly documents intent and keeps the + // generated workflow correct if the SDK default ever flips. + // `flutter analyze` already defaults `--fatal-warnings` to on with no + // CLI flag, so it doesn't need the same treatment. + final analyzeCmd = executable == 'dart' + ? 'dart analyze --fatal-warnings .' + : '$executable analyze .'; return ''' - run: dart format --set-exit-if-changed . - - run: $executable analyze . + - run: $analyzeCmd - name: Bloc Lint if: matrix.has_bloc_lint == true run: bloc lint . @@ -740,11 +801,21 @@ jobs: run: flutter test integration_test '''; - static const _codecovUploadStep = r''' + String _codecovUploadStep({required String? codecovTokenSecret}) { + return ''' - uses: codecov/codecov-action@v5 with: - flags: ${{ matrix.name }} -'''; + flags: \${{ matrix.name }} +${_codecovTokenLine(codecovTokenSecret)}'''; + } + + /// Emits the `token:` line for a codecov-action `with:` block, or the + /// empty string when no token secret is configured. Indented 10 spaces + /// to match `flags:` peers in both static and dynamic templates. + String _codecovTokenLine(String? secret) { + if (secret == null || secret.isEmpty) return ''; + return ' token: \${{ secrets.$secret }}\n'; + } void _writeCspellJob( StringBuffer buffer, diff --git a/packages/shorebird_ci/pubspec.yaml b/packages/shorebird_ci/pubspec.yaml index 7793be90..65b938a6 100644 --- a/packages/shorebird_ci/pubspec.yaml +++ b/packages/shorebird_ci/pubspec.yaml @@ -3,7 +3,7 @@ description: >- CI tooling for Dart and Flutter monorepos. Generates GitHub Actions workflows, resolves affected packages via dependency graphs, and verifies path filters stay in sync. -version: 0.2.1 +version: 0.2.2 homepage: https://shorebird.dev repository: https://github.com/shorebirdtech/shorebird/tree/main/packages/shorebird_ci topics: [ci, github-actions, monorepo, shorebird] diff --git a/packages/shorebird_ci/test/generate_command_test.dart b/packages/shorebird_ci/test/generate_command_test.dart index cec43b6e..689c9278 100644 --- a/packages/shorebird_ci/test/generate_command_test.dart +++ b/packages/shorebird_ci/test/generate_command_test.dart @@ -187,6 +187,73 @@ void main() { // The verify step must itself be present in the generated workflow. expect(yaml, contains('shorebird_ci verify')); }); + + test( + 'Dart analyze step emits --fatal-warnings explicitly', + () async { + createPackage(tempDir, 'packages/foo', 'foo'); + initGitRepo(tempDir); + + await runGenerate(tempDir); + final yaml = _readMain(tempDir); + + expect(yaml, contains('dart analyze --fatal-warnings .')); + }, + ); + + test( + 'Flutter analyze step does not need --fatal-warnings (default on)', + () async { + createPackage( + tempDir, + 'packages/flutter_pkg', + 'flutter_pkg', + flutterLine: 'any', + ); + initGitRepo(tempDir); + + await runGenerate(tempDir); + final yaml = _readMain(tempDir); + + expect(yaml, contains('flutter analyze .')); + expect(yaml, isNot(contains('flutter analyze --fatal-warnings'))); + }, + ); + + test( + '--codecov-token-secret threads token into codecov-action', + () async { + createPackage(tempDir, 'packages/foo', 'foo', addTestDir: true); + File(p.join(tempDir.path, 'codecov.yml')).writeAsStringSync(''); + initGitRepo(tempDir); + + await runGenerate( + tempDir, + extra: ['--codecov-token-secret', 'CODECOV_TOKEN'], + ); + final yaml = _readMain(tempDir); + + expect(yaml, contains(r'token: ${{ secrets.CODECOV_TOKEN }}')); + }, + ); + + test( + 'no --codecov-token-secret → no token plumbing emitted', + () async { + createPackage(tempDir, 'packages/foo', 'foo', addTestDir: true); + File(p.join(tempDir.path, 'codecov.yml')).writeAsStringSync(''); + initGitRepo(tempDir); + + await runGenerate(tempDir); + final yaml = _readMain(tempDir); + + // Asserting on `token:` alone is enough to prove the codecov + // token plumbing didn't fire. A broader assertion on `secrets.` + // would trip on any unrelated future step that references a + // GitHub Actions secret. + expect(yaml, isNot(contains('token:'))); + }, + ); }); group('generate --style static', () { @@ -390,6 +457,192 @@ void main() { expect(yaml, contains('cspell:')); expect(yaml, contains('streetsidesoftware/cspell-action')); }); + + test( + 'Dart reusable analyze step emits --fatal-warnings explicitly', + () async { + createPackage(tempDir, 'packages/foo', 'foo'); + initGitRepo(tempDir); + + await runGenerate(tempDir, extra: ['--style', 'static']); + final dartReusable = File( + p.join( + tempDir.path, + '.github', + 'workflows', + '_shorebird_ci_dart.yaml', + ), + ).readAsStringSync(); + + expect(dartReusable, contains('dart analyze --fatal-warnings .')); + }, + ); + + test( + 'Flutter reusable analyze step does not need --fatal-warnings', + () async { + createPackage( + tempDir, + 'packages/flutter_pkg', + 'flutter_pkg', + flutterLine: 'any', + ); + initGitRepo(tempDir); + + await runGenerate(tempDir, extra: ['--style', 'static']); + final flutterReusable = File( + p.join( + tempDir.path, + '.github', + 'workflows', + '_shorebird_ci_flutter.yaml', + ), + ).readAsStringSync(); + + expect(flutterReusable, contains('flutter analyze .')); + expect( + flutterReusable, + isNot(contains('flutter analyze --fatal-warnings')), + ); + }, + ); + + test( + '--codecov-token-secret emits secrets: inherit on each ' + 'orchestrator uses: call and threads token into reusable', + () async { + createPackage(tempDir, 'packages/foo', 'foo', addTestDir: true); + createPackage( + tempDir, + 'packages/flutter_pkg', + 'flutter_pkg', + flutterLine: 'any', + addTestDir: true, + ); + File(p.join(tempDir.path, 'codecov.yml')).writeAsStringSync(''); + initGitRepo(tempDir); + + await runGenerate( + tempDir, + extra: [ + '--style', + 'static', + '--codecov-token-secret', + 'CODECOV_TOKEN', + ], + ); + final mainYaml = _readMain(tempDir); + final dartReusable = File( + p.join( + tempDir.path, + '.github', + 'workflows', + '_shorebird_ci_dart.yaml', + ), + ).readAsStringSync(); + final flutterReusable = File( + p.join( + tempDir.path, + '.github', + 'workflows', + '_shorebird_ci_flutter.yaml', + ), + ).readAsStringSync(); + + // One `secrets: inherit` per package job (2 packages). + expect( + 'secrets: inherit'.allMatches(mainYaml).length, + equals(2), + ); + expect( + dartReusable, + contains(r'token: ${{ secrets.CODECOV_TOKEN }}'), + ); + expect( + flutterReusable, + contains(r'token: ${{ secrets.CODECOV_TOKEN }}'), + ); + }, + ); + + test( + 'no --codecov-token-secret → no secrets: inherit, no token', + () async { + createPackage(tempDir, 'packages/foo', 'foo', addTestDir: true); + File(p.join(tempDir.path, 'codecov.yml')).writeAsStringSync(''); + initGitRepo(tempDir); + + await runGenerate(tempDir, extra: ['--style', 'static']); + final mainYaml = _readMain(tempDir); + final dartReusable = File( + p.join( + tempDir.path, + '.github', + 'workflows', + '_shorebird_ci_dart.yaml', + ), + ).readAsStringSync(); + + expect(mainYaml, isNot(contains('secrets: inherit'))); + expect(dartReusable, isNot(contains('token:'))); + }, + ); + + test( + '--codecov-token-secret w/o codecov config → no plumbing emitted', + () async { + // Repo has no codecov config, so reusable workflow has no + // codecov-action step. The flag should be a no-op here even + // when passed. + createPackage(tempDir, 'packages/foo', 'foo', addTestDir: true); + initGitRepo(tempDir); + + await runGenerate( + tempDir, + extra: [ + '--style', + 'static', + '--codecov-token-secret', + 'CODECOV_TOKEN', + ], + ); + final mainYaml = _readMain(tempDir); + + expect(mainYaml, isNot(contains('secrets: inherit'))); + }, + ); + + test( + '--codecov-token-secret accepts an arbitrary secret name', + () async { + createPackage(tempDir, 'packages/foo', 'foo', addTestDir: true); + File(p.join(tempDir.path, 'codecov.yml')).writeAsStringSync(''); + initGitRepo(tempDir); + + await runGenerate( + tempDir, + extra: [ + '--style', + 'static', + '--codecov-token-secret', + 'MY_CUSTOM_NAME', + ], + ); + final dartReusable = File( + p.join( + tempDir.path, + '.github', + 'workflows', + '_shorebird_ci_dart.yaml', + ), + ).readAsStringSync(); + + expect( + dartReusable, + contains(r'token: ${{ secrets.MY_CUSTOM_NAME }}'), + ); + }, + ); }); group('dependabot.yml', () {