fix(shorebird_ci): path-derived job keys + has_unit_tests gate (#3763)

This commit is contained in:
nickshorebird
2026-05-14 12:00:43 -04:00
committed by GitHub
parent e4081a716d
commit bca78a8db5
9 changed files with 255 additions and 65 deletions
+5
View File
@@ -1,5 +1,10 @@
<!-- cspell:words toplevel --> <!-- cspell:words toplevel -->
# 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.
- Static reusable workflows now gate the test and codecov-upload steps on a new `has_unit_tests` input. Packages without a `test/` directory no longer attempt to run `dart test` / `flutter test` or upload coverage. The input defaults to `true` for back-compat with workflows that don't pass it.
# 0.2.0 # 0.2.0
- Generated dynamic workflow now pins `fetch-depth: 0` on the setup-job checkout. Without it, `affected_packages` fails on every PR because the default shallow checkout doesn't include `origin/main`. The static main workflow gets the same fix so dorny/paths-filter can diff on push events. - Generated dynamic workflow now pins `fetch-depth: 0` on the setup-job checkout. Without it, `affected_packages` fails on every PR because the default shallow checkout doesn't include `origin/main`. The static main workflow gets the same fix so dorny/paths-filter can diff on push events.
@@ -7,6 +7,7 @@ import 'package:shorebird_ci/src/commands/repo_root_option.dart';
import 'package:shorebird_ci/src/dependency_resolver.dart'; import 'package:shorebird_ci/src/dependency_resolver.dart';
import 'package:shorebird_ci/src/flutter_version_resolver.dart'; import 'package:shorebird_ci/src/flutter_version_resolver.dart';
import 'package:shorebird_ci/src/package_description.dart'; import 'package:shorebird_ci/src/package_description.dart';
import 'package:shorebird_ci/src/package_slug.dart';
import 'package:shorebird_ci/src/repository_analyzer.dart'; import 'package:shorebird_ci/src/repository_analyzer.dart';
import 'package:shorebird_ci/src/repository_description.dart'; import 'package:shorebird_ci/src/repository_description.dart';
@@ -209,6 +210,11 @@ updates:
required List<PackageDescription> packages, required List<PackageDescription> packages,
}) { }) {
final resolver = DependencyResolver(repository.root.path); final resolver = DependencyResolver(repository.root.path);
final slugs = computePackageSlugs(
packages: packages,
repoRoot: repository.root.path,
);
final buffer = StringBuffer() final buffer = StringBuffer()
..write(''' ..write('''
# Generated by shorebird_ci --style static. Safe to edit. # Generated by shorebird_ci --style static. Safe to edit.
@@ -230,10 +236,8 @@ jobs:
'''); ''');
for (final package in packages) { for (final package in packages) {
buffer.writeln( final slug = slugs[package]!;
' ${package.name}: ' buffer.writeln(' $slug: \${{ steps.filter.outputs.$slug }}');
'\${{ steps.filter.outputs.${package.name} }}',
);
} }
// Verify first so we fail fast if the dorny filters below have // Verify first so we fail fast if the dorny filters below have
@@ -261,8 +265,9 @@ jobs:
from: repository.root.path, from: repository.root.path,
); );
final sortedDeps = resolver.resolve(packageDir).toList()..sort(); final sortedDeps = resolver.resolve(packageDir).toList()..sort();
final slug = slugs[package]!;
buffer.writeln(' ${package.name}:'); buffer.writeln(' $slug:');
for (final dep in sortedDeps) { for (final dep in sortedDeps) {
buffer.writeln(' - $dep/**'); buffer.writeln(' - $dep/**');
} }
@@ -285,16 +290,18 @@ jobs:
final reusable = isFlutter final reusable = isFlutter
? '_shorebird_ci_flutter.yaml' ? '_shorebird_ci_flutter.yaml'
: '_shorebird_ci_dart.yaml'; : '_shorebird_ci_dart.yaml';
final slug = slugs[package]!;
buffer.write(''' buffer.write('''
${package.name}: $slug:
needs: changes needs: changes
if: needs.changes.outputs.${package.name} == 'true' if: needs.changes.outputs.$slug == 'true'
uses: ./.github/workflows/$reusable uses: ./.github/workflows/$reusable
with: with:
package_name: ${package.name} package_name: ${package.name}
package_path: $packageDir package_path: $packageDir
has_bloc_lint: ${RepositoryAnalyzer.dependsOnBlocLint(root: package.root)} has_bloc_lint: ${RepositoryAnalyzer.dependsOnBlocLint(root: package.root)}
has_unit_tests: ${RepositoryAnalyzer.hasUnitTests(root: package.root)}
subpackages: "${subpackages.join(' ')}" subpackages: "${subpackages.join(' ')}"
'''); ''');
@@ -324,18 +331,21 @@ jobs:
final testStep = hasCodecov final testStep = hasCodecov
? r''' ? r'''
- name: Run Tests - name: Run Tests
if: inputs.has_unit_tests
working-directory: ${{ inputs.package_path }} working-directory: ${{ inputs.package_path }}
run: | run: |
dart pub global activate coverage && \ dart pub global activate coverage && \
dart test --coverage=coverage && \ dart test --coverage=coverage && \
dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib --check-ignore dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib --check-ignore
- uses: codecov/codecov-action@v5 - if: inputs.has_unit_tests
uses: codecov/codecov-action@v5
with: with:
flags: ${{ inputs.package_name }} flags: ${{ inputs.package_name }}
working-directory: ${{ inputs.package_path }} working-directory: ${{ inputs.package_path }}
''' '''
: r''' : r'''
- working-directory: ${{ inputs.package_path }} - if: inputs.has_unit_tests
working-directory: ${{ inputs.package_path }}
run: dart test run: dart test
'''; ''';
@@ -355,6 +365,10 @@ on:
required: false required: false
default: false default: false
type: boolean type: boolean
has_unit_tests:
required: false
default: true
type: boolean
subpackages: subpackages:
required: false required: false
default: "" default: ""
@@ -392,15 +406,18 @@ $testStep''';
String _buildFlutterReusableWorkflow({required bool hasCodecov}) { String _buildFlutterReusableWorkflow({required bool hasCodecov}) {
final testStep = hasCodecov final testStep = hasCodecov
? r''' ? r'''
- working-directory: ${{ inputs.package_path }} - if: inputs.has_unit_tests
working-directory: ${{ inputs.package_path }}
run: flutter test --coverage run: flutter test --coverage
- uses: codecov/codecov-action@v5 - if: inputs.has_unit_tests
uses: codecov/codecov-action@v5
with: with:
flags: ${{ inputs.package_name }} flags: ${{ inputs.package_name }}
working-directory: ${{ inputs.package_path }} working-directory: ${{ inputs.package_path }}
''' '''
: r''' : r'''
- working-directory: ${{ inputs.package_path }} - if: inputs.has_unit_tests
working-directory: ${{ inputs.package_path }}
run: flutter test run: flutter test
'''; ''';
@@ -428,6 +445,10 @@ on:
required: false required: false
default: false default: false
type: boolean type: boolean
has_unit_tests:
required: false
default: true
type: boolean
subpackages: subpackages:
required: false required: false
default: "" default: ""
@@ -6,6 +6,7 @@ import 'package:shorebird_ci/src/commands/repo_root_option.dart';
import 'package:shorebird_ci/src/dependency_resolver.dart'; import 'package:shorebird_ci/src/dependency_resolver.dart';
import 'package:shorebird_ci/src/dorny_filter.dart'; import 'package:shorebird_ci/src/dorny_filter.dart';
import 'package:shorebird_ci/src/package_description.dart'; import 'package:shorebird_ci/src/package_description.dart';
import 'package:shorebird_ci/src/package_slug.dart';
import 'package:shorebird_ci/src/repository_analyzer.dart'; import 'package:shorebird_ci/src/repository_analyzer.dart';
/// Marker comment that the `generate` command writes into dynamic /// Marker comment that the `generate` command writes into dynamic
@@ -21,9 +22,11 @@ const dynamicCoverageMarker = '# shorebird_ci-managed: dynamic';
/// - **Dynamic**: a workflow that calls `shorebird_ci affected_packages` /// - **Dynamic**: a workflow that calls `shorebird_ci affected_packages`
/// covers every package automatically. One dynamic workflow means no /// covers every package automatically. One dynamic workflow means no
/// missing packages. /// missing packages.
/// - **Static**: each package name appears in a `dorny/paths-filter` /// - **Static**: each package's slug (see [computePackageSlugs]) appears
/// block somewhere. Missing packages are reported with the dorny /// in a `dorny/paths-filter` block somewhere. For most packages the
/// entry that should be added (including transitive deps). /// slug is just the package name; when two packages share a name the
/// slug is `<parent_dir>_<name>`. Missing packages are reported with
/// the dorny entry that should be added (including transitive deps).
class VerifyCommand extends Command<int> with RepoRootOption { class VerifyCommand extends Command<int> with RepoRootOption {
/// Creates a [VerifyCommand]. /// Creates a [VerifyCommand].
VerifyCommand() { VerifyCommand() {
@@ -98,15 +101,19 @@ class VerifyCommand extends Command<int> with RepoRootOption {
return 0; return 0;
} }
final slugs = computePackageSlugs(
packages: allPackages,
repoRoot: repoRoot,
);
final missing = <PackageDescription>[]; final missing = <PackageDescription>[];
for (final pkg in allPackages) { for (final pkg in allPackages) {
if (ignoreSet.contains(pkg.name)) continue; if (ignoreSet.contains(pkg.name)) continue;
final workflows = coverageMap[pkg.name]; final slug = slugs[pkg]!;
final workflows = coverageMap[slug];
if (workflows != null) { if (workflows != null) {
stdout.writeln( final label = slug == pkg.name ? pkg.name : '${pkg.name} ($slug)';
'OK: ${pkg.name} (${workflows.join(', ')})', stdout.writeln('OK: $label (${workflows.join(', ')})');
);
} else { } else {
missing.add(pkg); missing.add(pkg);
} }
@@ -124,11 +131,13 @@ class VerifyCommand extends Command<int> with RepoRootOption {
final packageDir = posixRelative(pkg.rootPath, from: repoRoot); final packageDir = posixRelative(pkg.rootPath, from: repoRoot);
final deps = resolver.resolve(packageDir); final deps = resolver.resolve(packageDir);
final sortedDeps = deps.toList()..sort(); final sortedDeps = deps.toList()..sort();
final slug = slugs[pkg]!;
final label = slug == pkg.name ? pkg.name : '${pkg.name} ($slug)';
stdout stdout
..writeln('MISSING: ${pkg.name}') ..writeln('MISSING: $label')
..writeln(' Add this entry to a dorny paths-filter block:') ..writeln(' Add this entry to a dorny paths-filter block:')
..writeln(' ${pkg.name}:'); ..writeln(' $slug:');
for (final dep in sortedDeps) { for (final dep in sortedDeps) {
stdout.writeln(' - $dep/**'); stdout.writeln(' - $dep/**');
} }
@@ -0,0 +1,50 @@
import 'package:shorebird_ci/src/package_description.dart';
import 'package:shorebird_ci/src/repository_analyzer.dart';
/// Maps each package to the YAML map key used for its dorny filter,
/// changes-job output, and per-package job in the static main workflow.
///
/// A package's slug defaults to its `name:`. When two or more packages
/// share a `name:`, those packages fall back to `<parent_dir>_<name>`
/// to disambiguate. Parent dirs are normalized to pub's
/// `[a-z][a-z0-9_]*` rules so the slug can be used in GitHub Actions
/// expressions. `generate` writes these slugs and `verify` reads them,
/// so both commands must agree on the rule.
Map<PackageDescription, String> computePackageSlugs({
required List<PackageDescription> packages,
required String repoRoot,
}) {
final byName = <String, List<PackageDescription>>{};
for (final pkg in packages) {
byName.putIfAbsent(pkg.name, () => []).add(pkg);
}
final duplicateNames = {
for (final entry in byName.entries)
if (entry.value.length > 1) entry.key,
};
return {
for (final pkg in packages)
pkg: _slugFor(
package: pkg,
repoRoot: repoRoot,
duplicateNames: duplicateNames,
),
};
}
String _slugFor({
required PackageDescription package,
required String repoRoot,
required Set<String> duplicateNames,
}) {
if (!duplicateNames.contains(package.name)) return package.name;
final relative = posixRelative(package.rootPath, from: repoRoot);
final parts = relative.split('/');
if (parts.length < 2) return package.name;
final parent = parts[parts.length - 2].toLowerCase().replaceAll(
RegExp('[^a-z0-9_]'),
'_',
);
return '${parent}_${package.name}';
}
@@ -87,8 +87,6 @@ class RepositoryAnalyzer {
.whereType<PackageDescription>() .whereType<PackageDescription>()
.toList(); .toList();
_checkForDuplicateNames(packageDescriptions);
return RepositoryDescription( return RepositoryDescription(
packages: packageDescriptions, packages: packageDescriptions,
root: repositoryRoot, root: repositoryRoot,
@@ -97,29 +95,6 @@ class RepositoryAnalyzer {
); );
} }
static void _checkForDuplicateNames(
List<PackageDescription> packages,
) {
final byName = <String, List<PackageDescription>>{};
for (final pkg in packages) {
byName.putIfAbsent(pkg.name, () => []).add(pkg);
}
final duplicates = byName.entries.where((e) => e.value.length > 1);
if (duplicates.isEmpty) return;
final buffer = StringBuffer(
'Duplicate package names found. Each package in the workspace '
'must have a unique `name:` in its pubspec.yaml.\n',
);
for (final entry in duplicates) {
buffer.writeln(' ${entry.key}:');
for (final pkg in entry.value) {
buffer.writeln(' - ${pkg.rootPath}');
}
}
throw StateError(buffer.toString().trimRight());
}
/// Allowed characters in a package or subpackage path: letters, /// Allowed characters in a package or subpackage path: letters,
/// digits, `_`, `-`, `.`, and `/`. Any other character (whitespace, /// digits, `_`, `-`, `.`, and `/`. Any other character (whitespace,
/// shell metacharacters, Unicode) is rejected. /// shell metacharacters, Unicode) is rejected.
@@ -144,10 +119,9 @@ class RepositoryAnalyzer {
/// Pub's own naming convention for packages: lowercase letter or /// Pub's own naming convention for packages: lowercase letter or
/// underscore start, then lowercase letters, digits, and underscores. /// underscore start, then lowercase letters, digits, and underscores.
/// `pubspec.yaml` is just YAML pub doesn't gate this name until you /// `pubspec.yaml` is just YAML, and pub doesn't gate this name until
/// publish — so a malformed name can land here and get embedded as /// publish. A malformed name flows into the slug used as a YAML map
/// a YAML map key in the generated workflow. Validate at analysis /// key in the generated workflow, so validate at analysis time.
/// time instead.
static final _safePackageNameRegex = RegExp(r'^[a-z][a-z0-9_]*$'); static final _safePackageNameRegex = RegExp(r'^[a-z][a-z0-9_]*$');
static void _requireSafePackageName(String name, {required String source}) { static void _requireSafePackageName(String name, {required String source}) {
+1 -1
View File
@@ -3,7 +3,7 @@ description: >-
CI tooling for Dart and Flutter monorepos. Generates GitHub Actions CI tooling for Dart and Flutter monorepos. Generates GitHub Actions
workflows, resolves affected packages via dependency graphs, and workflows, resolves affected packages via dependency graphs, and
verifies path filters stay in sync. verifies path filters stay in sync.
version: 0.2.0 version: 0.2.1
homepage: https://shorebird.dev homepage: https://shorebird.dev
repository: https://github.com/shorebirdtech/shorebird/tree/main/packages/shorebird_ci repository: https://github.com/shorebirdtech/shorebird/tree/main/packages/shorebird_ci
topics: [ci, github-actions, monorepo, shorebird] topics: [ci, github-actions, monorepo, shorebird]
@@ -261,6 +261,110 @@ void main() {
}, },
); );
test(
'has_unit_tests gates the test + codecov steps',
() async {
// Two packages: one with a test/ dir, one without. The main
// workflow should pass has_unit_tests per package, and the
// reusable workflow should gate its test step on the input.
createPackage(
tempDir,
'packages/with_tests',
'with_tests',
addTestDir: true,
);
createPackage(tempDir, 'packages/no_tests', 'no_tests');
initGitRepo(tempDir);
await runGenerate(tempDir, extra: ['--style', 'static']);
final main = _readMain(tempDir);
expect(
main,
matches(
RegExp(
'with_tests:[^#]*has_unit_tests: true',
dotAll: true,
),
),
);
expect(
main,
matches(
RegExp(
'no_tests:[^#]*has_unit_tests: false',
dotAll: true,
),
),
);
final reusable = File(
p.join(
tempDir.path,
'.github',
'workflows',
'_shorebird_ci_dart.yaml',
),
).readAsStringSync();
expect(reusable, contains('has_unit_tests:'));
expect(reusable, contains('if: inputs.has_unit_tests'));
},
);
test(
'YAML map keys default to the package name when unique',
() async {
// A repo where every package has a unique `name:` — the YAML
// keys are just the names, no path prefix.
createPackage(tempDir, 'packages/foo', 'foo');
createPackage(tempDir, 'apps/bar', 'bar');
initGitRepo(tempDir);
await runGenerate(tempDir, extra: ['--style', 'static']);
final yaml = _readMain(tempDir);
for (final slug in ['foo', 'bar']) {
expect(yaml, contains('$slug:'));
expect(yaml, contains('needs.changes.outputs.$slug'));
}
// No path-prefixed variants leak in.
expect(yaml, isNot(contains('packages_foo')));
expect(yaml, isNot(contains('apps_bar')));
},
);
test(
'YAML map keys fall back to <parent>_<name> on collision',
() async {
// Two packages w/ the same `name:` at different parent dirs:
// pub allows this, and shorebird_ci must disambiguate the YAML
// keys by walking up one path segment. `name: harness` under
// `apps/alpha/` becomes `alpha_harness`, under `apps/beta/`
// becomes `beta_harness`. A unique-named neighbor in the same
// repo keeps its plain name.
createPackage(tempDir, 'apps/alpha/harness', 'harness');
createPackage(tempDir, 'apps/beta/harness', 'harness');
createPackage(tempDir, 'packages/foo', 'foo');
initGitRepo(tempDir);
await runGenerate(tempDir, extra: ['--style', 'static']);
final yaml = _readMain(tempDir);
for (final slug in ['alpha_harness', 'beta_harness', 'foo']) {
// Each slug appears as a dorny filter key, an outputs entry,
// and a per-package job key.
expect(yaml, contains('$slug:'));
expect(yaml, contains('needs.changes.outputs.$slug'));
}
// The actual package name still flows through as the
// `package_name:` input value (used for codecov flags).
expect(yaml, contains('package_name: harness'));
expect(yaml, contains('package_name: foo'));
// The unique package does not get prefixed.
expect(yaml, isNot(contains('packages_foo')));
},
);
test('verify step comes before dorny filter', () async { test('verify step comes before dorny filter', () async {
createPackage(tempDir, 'packages/foo', 'foo'); createPackage(tempDir, 'packages/foo', 'foo');
initGitRepo(tempDir); initGitRepo(tempDir);
@@ -150,25 +150,21 @@ void main() {
); );
}); });
test('throws on duplicate package names', () async { test('allows packages sharing a `name:` at different paths', () async {
// Two packages declaring `name: example` would silently collide // Two packages can legitimately share a `name:` (pub allows it
// when used as YAML map keys in the generated workflow. Fail // when the packages are unrelated, e.g. test harnesses sitting
// loudly instead. // next to multiple apps). The analyzer must surface both; the
// workflow generator disambiguates by path when it emits YAML
// map keys.
createPackage(tempDir, 'a/example', 'example'); createPackage(tempDir, 'a/example', 'example');
createPackage(tempDir, 'b/example', 'example'); createPackage(tempDir, 'b/example', 'example');
initGitRepo(tempDir); initGitRepo(tempDir);
final analyzer = RepositoryAnalyzer(); final analyzer = RepositoryAnalyzer();
expect( final repo = analyzer.analyze(repositoryRoot: tempDir);
() => analyzer.analyze(repositoryRoot: tempDir),
throwsA( expect(repo.packages.length, 2);
isA<StateError>().having( expect(repo.packages.every((p) => p.name == 'example'), isTrue);
(e) => e.message,
'message',
contains('Duplicate package names'),
),
),
);
}); });
test( test(
@@ -125,6 +125,37 @@ jobs:
expect(await runVerify(tempDir), 1); expect(await runVerify(tempDir), 1);
}); });
test(
'colliding package names match by slug, not by name',
() async {
// Two packages share `name: harness` at different parent dirs.
// `generate` emits the dorny filter keys as `alpha_harness` and
// `beta_harness`. Verify must compute the same slugs to
// recognize coverage; looking up by plain `pkg.name`
// ("harness") would miss both.
createPackage(tempDir, 'apps/alpha/harness', 'harness');
createPackage(tempDir, 'apps/beta/harness', 'harness');
_writeWorkflow(tempDir, 'ci.yaml', '''
name: CI
on: [push]
jobs:
changes:
runs-on: ubuntu-latest
steps:
- uses: dorny/paths-filter@v3
with:
filters: |
alpha_harness:
- apps/alpha/harness/**
beta_harness:
- apps/beta/harness/**
''');
initGitRepo(tempDir);
expect(await runVerify(tempDir), 0);
},
);
test('exposes a non-empty description', () { test('exposes a non-empty description', () {
expect(VerifyCommand().description, isNotEmpty); expect(VerifyCommand().description, isNotEmpty);
}); });