feat(shorebird_ci): codecov token plumbing + explicit fatal-warnings (#3772)

This commit is contained in:
nickshorebird
2026-05-15 14:37:11 -04:00
committed by GitHub
parent 431eaf4b53
commit 0e92ab7dd9
6 changed files with 375 additions and 27 deletions
+5
View File
@@ -1,5 +1,10 @@
<!-- cspell:words toplevel -->
# 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 <NAME>` 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.<NAME> }}`. 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 `<parent_dir>_<package_name>` 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.
+19
View File
@@ -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 <NAME>`
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.<NAME> }}`.
- `--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,
@@ -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 .
@@ -46,6 +46,18 @@ class GenerateCommand extends Command<int> 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.<NAME> }}` 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<int> 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<int> 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<String, String> _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<PackageDescription> 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,
+1 -1
View File
@@ -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]
@@ -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', () {