feat: add shorebird_ci package (#3721)

This commit is contained in:
Eric Seidel
2026-04-29 09:13:09 -07:00
committed by GitHub
parent a8def6488b
commit e2f4332533
45 changed files with 4539 additions and 0 deletions
+6
View File
@@ -7,6 +7,7 @@ words:
- aapt
- aarch
- aars # Android Archive
- agentic
- aidl # Android AIDL interface definition files
- allprojects # From gradle files
- altool
@@ -74,6 +75,7 @@ words:
- lproj
- madd # From ./packages/redis_client
- mbps
- metacharacters
- mget # From ./packages/redis_client
- metadatas
- mktemp
@@ -90,6 +92,7 @@ words:
- patchability
- pana
- pbxproj
- pinact # Third-party action-pinning tool referenced in shorebird_ci
- Perfetto # Chrome trace viewer at ui.perfetto.dev
- pkcs
- podfile
@@ -111,6 +114,7 @@ words:
- reimplementation
- reinit
- requirepass # From .github dir, doesn't show up in "**" check?
- reusables
- spawnee
- Retryable
- RSAPKCS
@@ -125,6 +129,7 @@ words:
- SIGSTOP
- storepass # From .github/workflows/e2e.yaml
- storyboardc
- subpackages
- symbolication
- syskeys # From adb.dart
- subosito # From .github dir, doesn't show up in "**" check?
@@ -148,6 +153,7 @@ words:
- VCRUNTIME
- Verdana
- vmcode
- worktrees
- writeln
- WRONGPASS
- xcarchive
+9
View File
@@ -0,0 +1,9 @@
# Contributing
We are happy to accept contributions!
## Developing
### Running Tests
All you need to do is run `dart test`.
+19
View File
@@ -0,0 +1,19 @@
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+113
View File
@@ -0,0 +1,113 @@
# shorebird_ci
CI tooling for Dart and Flutter monorepos. Scans your repo, generates a
GitHub Actions workflow, and verifies that every package has coverage.
Designed to be used by both humans and AI agents.
## Install
Once published to pub.dev:
```sh
dart pub global activate shorebird_ci
```
From a local checkout (current state — not yet on pub.dev):
```sh
dart pub global activate --source path packages/shorebird_ci
```
## Commands
| Command | Used by | Purpose |
|---|---|---|
| `generate` | human / Claude at setup | Write `.github/workflows/shorebird_ci.yaml` |
| `verify` | human / Claude for health checks | Fail if any package is missing CI coverage |
| `affected_packages` | CI runtime (dynamic workflows) | Emit JSON `[{name, path, ...}]` for affected packages |
| `flutter_version` | CI runtime (Flutter packages) | Resolve Flutter version from pubspec (see note below) |
| `update_actions` | human / Claude for maintenance | Bump `uses:` versions in workflow files to latest major |
## Using it
```sh
shorebird_ci generate --repo-root . --dry-run # review
shorebird_ci generate --repo-root . # write
```
The tool auto-detects Dart vs. Flutter, Dart workspaces, codecov,
cspell, nested subpackages, bloc_lint, integration tests, and pinned
Flutter versions. The generated workflow includes `shorebird_ci verify`
in its setup job, so CI coverage is checked on every PR.
`generate` also writes `.github/dependabot.yml` (if missing) so action
versions stay current over time.
## How the generated workflow works
Two-stage structure. A `setup` job checks out the code, runs
`shorebird_ci verify` as a sanity check, then runs
`shorebird_ci affected_packages` to compute which packages the PR
touches (including transitive dependents via the Dart dep graph). A
matrix job fans out over only the affected packages, running per
entry: checkout, SDK setup, pub get (plus nested subpackages), format,
analyze, bloc lint (if bloc_lint is a dep), tests (with coverage if
codecov is configured), integration tests (if Flutter + `integration_test/`
exists), codecov upload.
Plus a CSpell job if a cspell config file exists.
Adding or removing packages requires no workflow changes — the setup
job discovers them at runtime.
### `--style static` (advanced)
`generate --style static` emits a pre-computed dorny `filters:` block,
not a full workflow. You paste it into your own workflow, wire up the
dorny step and per-package jobs yourself, and run `verify` to catch
path drift. This is for people who already have a dorny-based pipeline
and want help keeping the filter paths in sync with the Dart dep
graph — not a drop-in alternative to the default.
The dynamic default pays a small setup cost per PR — roughly 1530
seconds to check out, install the Dart SDK, `pub global activate`
shorebird_ci, and run `affected_packages`. That cost can probably be
cut a lot in the future (prebuilt snapshot, a composite action), but
it's what you pay today. For most repos it's noise. If you have a
high-volume monorepo where most PRs don't touch Dart, static lets the
workflow skip entirely at the trigger level.
## For AI agents
This tool handles the **deterministic parts** (package discovery, dep
graph, workflow generation). You handle the **judgment calls**
merging with existing workflows, naming, resolving conflicts.
If the repo already has `.github/workflows/*.yaml`, read them before
generating. Look for existing Dart CI that would be superseded,
duplicate job names, overlapping path filters. Ask the user whether
to replace existing CI or run alongside.
**Watch for:** custom runner requirements (self-hosted, ARM, etc.).
Generated workflow defaults to `ubuntu-latest`.
**When `verify` reports missing packages** (only possible in repos
using static dorny filters): the tool outputs the exact dorny entry
with transitive deps computed. You decide which workflow file and
which filter group to add it to — read the existing structure and
make the call.
**`--ignore`:** for packages that intentionally have no CI (e.g.,
`e2e` test packages): `shorebird_ci verify --ignore e2e`.
**Generated file is safe to edit.** It's a normal YAML file, not a
locked artifact. The tool's ongoing role is `verify`, not
regeneration.
## A note on `flutter_version`
This command exists because `subosito/flutter-action` accepts only
exact version strings — it can't resolve constraints like
`>=3.19.0 <4.0.0` from `environment.flutter`. This arguably belongs
upstream. If `flutter-action` (or the Flutter SDK) ships equivalent
resolution, this command should be deprecated.
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
@@ -0,0 +1,7 @@
import 'dart:io';
import 'package:shorebird_ci/src/shorebird_ci_command_runner.dart';
Future<void> main(List<String> args) async {
exit(await ShorebirdCiCommandRunner().run(args) ?? 0);
}
@@ -0,0 +1,11 @@
/// CI tooling for Dart and Flutter monorepos.
library;
export 'src/action_versions.dart';
export 'src/dependency_resolver.dart';
export 'src/dorny_filter.dart';
export 'src/flutter_version_resolver.dart';
export 'src/git.dart';
export 'src/package_description.dart';
export 'src/repository_analyzer.dart';
export 'src/repository_description.dart';
@@ -0,0 +1,134 @@
import 'dart:convert';
import 'dart:io';
// Why does this exist?
//
// Dependabot handles action version bumps over time, but there's no
// first-party way to say "update my action versions right now." It runs
// on GitHub's schedule (daily/weekly) and can only be nudged via the
// "Check for updates" button in the UI. No `gh` subcommand, no local
// CLI — the closest is an experimental dependabot-cli in Go that isn't
// widely used, or third-party tools like pinact and renovate.
//
// Since `shorebird_ci generate` wants to emit current versions the
// moment a user runs it (not "eventually, after Dependabot catches
// up"), we need to do it ourselves. This is ~80 lines of "scan for
// `uses:` references, hit /releases/latest, rewrite major version."
//
// If GitHub (or anyone else) ships a proper CLI for this, delete this
// file and use that instead.
/// Resolves the latest major version tag (e.g. `'v5'`) for the action
/// at `<owner>/<repo>` on GitHub. Returns `null` if no version can be
/// determined (network failure, missing release, malformed tag, etc.).
typedef LatestMajorResolver = Future<String?> Function(String repo);
/// Scans [workflowContent] for GitHub Actions `uses:` references and
/// returns a copy with each version bumped to the current latest major
/// (e.g., `@v4` becomes `@v5` if v5 is the latest major release).
///
/// Only `uses:` references themselves are rewritten — mentions of the
/// same `owner/repo@vN` string in YAML comments or `run:` shell strings
/// are left alone.
///
/// Queries `repos/<owner>/<repo>/releases/latest` on GitHub's public API.
/// If the lookup fails for a given action (network down, not found,
/// rate limited), that action's version is left unchanged.
///
/// [resolveLatestMajor] is exposed for testing — production callers should
/// leave it `null` to use the default GitHub API resolver.
///
/// Returns the updated content. If nothing changed, the return value is
/// identical to the input.
Future<String> updateActionVersions(
String workflowContent, {
LatestMajorResolver? resolveLatestMajor,
}) async {
final actions = _extractActions(workflowContent);
if (actions.isEmpty) return workflowContent;
final resolver = resolveLatestMajor ?? _defaultResolver;
final latestByRepo = <String, String?>{};
for (final action in actions) {
latestByRepo[action.repo] ??= await resolver(action.repo);
}
// Replace at regex match positions only, so mentions in comments or
// `run:` strings (e.g. `echo "see actions/checkout@v4"`) are left alone.
return workflowContent.replaceAllMapped(_usesRegex, (match) {
final whole = match.group(0)!;
final repo = match.group(1)!;
final version = match.group(2)!;
if (repo.startsWith('./')) return whole;
final latest = latestByRepo[repo];
if (latest == null || latest == version) return whole;
return 'uses: $repo@$latest';
});
}
// coverage:ignore-start
// Wraps the GitHub API call and is exercised end-to-end by the
// `generate` workflow at runtime. The resolver indirection above lets
// tests cover the version-bumping logic without touching the network.
Future<String?> _defaultResolver(String repo) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 5);
try {
return await _latestMajor(client, repo);
} finally {
client.close();
}
}
// coverage:ignore-end
class _ActionRef {
_ActionRef(this.repo, this.version);
final String repo;
final String version;
}
/// Matches `uses: owner/repo@version` where version starts with `v`
/// followed by a digit. Deliberately skips SHA pins and `@main`/`@master`.
final _usesRegex = RegExp(
r'uses:\s+([a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+)@(v\d+(?:\.\d+)*)',
);
Set<_ActionRef> _extractActions(String content) {
final refs = <String, _ActionRef>{};
for (final match in _usesRegex.allMatches(content)) {
final repo = match.group(1)!;
final version = match.group(2)!;
// Skip local action references (they start with ./ or are single-segment).
if (repo.startsWith('./')) continue;
refs['$repo@$version'] = _ActionRef(repo, version);
}
return refs.values.toSet();
}
// coverage:ignore-start
Future<String?> _latestMajor(HttpClient client, String repo) async {
final uri = Uri.parse(
'https://api.github.com/repos/$repo/releases/latest',
);
try {
final request = await client.getUrl(uri);
request.headers
..add('Accept', 'application/vnd.github+json')
..add('User-Agent', 'shorebird_ci');
final response = await request.close();
if (response.statusCode != 200) return null;
final body = await response.transform(utf8.decoder).join();
final json = jsonDecode(body) as Map<String, dynamic>;
final tag = json['tag_name'] as String?;
if (tag == null) return null;
final match = RegExp(r'^v?(\d+)').firstMatch(tag);
if (match == null) return null;
return 'v${match.group(1)}';
} on Exception {
return null;
}
}
// coverage:ignore-end
@@ -0,0 +1,67 @@
import 'dart:io';
import 'package:shorebird_ci/src/flutter_version_resolver.dart';
import 'package:shorebird_ci/src/repository_analyzer.dart';
/// Computes the list of package metadata maps consumed by the dynamic
/// CI workflow's `matrix.include`.
///
/// Each entry has the shape documented on `AffectedPackagesCommand`.
/// [sdkFilter] may be `'dart'`, `'flutter'`, or `null` (no filter).
List<Map<String, Object?>> affectedPackagesMetadata({
required Directory repoRoot,
String? baseRef,
String? headRef,
String? sdkFilter,
bool all = false,
RepositoryAnalyzer? analyzer,
}) {
final a = analyzer ?? RepositoryAnalyzer();
final repository = a.analyze(repositoryRoot: repoRoot);
if (repository.packages.isEmpty) return const [];
var packages = all
? repository.packages
: a
.affectedPackages(
repository: repository,
baseRef: baseRef ?? 'origin/main',
headRef: headRef ?? 'HEAD',
)
.toList();
if (sdkFilter != null) {
packages = packages.where((pkg) {
final isFlutter = RepositoryAnalyzer.dependsOnFlutter(root: pkg.root);
return sdkFilter == 'flutter' ? isFlutter : !isFlutter;
}).toList();
}
final sorted = packages.toList()..sort((a, b) => a.name.compareTo(b.name));
return sorted.map((pkg) {
final isFlutter = RepositoryAnalyzer.dependsOnFlutter(root: pkg.root);
final subpackages =
RepositoryAnalyzer.subpackages(package: pkg)
.map((sub) => posixRelative(sub.rootPath, from: pkg.rootPath))
.toList()
..sort();
return {
'name': pkg.name,
'path': posixRelative(pkg.rootPath, from: repoRoot.path),
'sdk': isFlutter ? 'flutter' : 'dart',
'flutter_version': isFlutter
? (resolveFlutterVersion(packagePath: pkg.rootPath) ?? '')
: '',
'has_bloc_lint': RepositoryAnalyzer.dependsOnBlocLint(root: pkg.root),
'has_integration_tests': RepositoryAnalyzer.hasIntegrationTests(
root: pkg.root,
),
// Space-separated so the matrix job can iterate with a shell for
// loop. Safe because RepositoryAnalyzer rejects any package path
// containing shell metacharacters; see _requireSafePath there.
'subpackages': subpackages.join(' '),
};
}).toList();
}
@@ -0,0 +1,13 @@
import 'package:path/path.dart' as p;
/// Candidate paths for codecov config files.
///
/// See https://docs.codecov.com/docs/codecov-yaml#can-i-name-the-file-codecovyml
final codecovFileNames = {
'codecov.yml',
'.codecov.yml',
p.join('.github', 'codecov.yml'),
p.join('.github', '.codecov.yml'),
p.join('dev', 'codecov.yml'),
p.join('dev', '.codecov.yml'),
};
@@ -0,0 +1,77 @@
import 'dart:convert';
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:shorebird_ci/src/affected_packages.dart';
import 'package:shorebird_ci/src/commands/repo_root_option.dart';
/// Lists packages affected by changes between two git refs.
///
/// Outputs a JSON array of objects designed to be consumed by GitHub
/// Actions `fromJSON()` in a `matrix.include` strategy. Each entry
/// has this shape:
///
/// ```json
/// {
/// "name": "foo",
/// "path": "packages/foo",
/// "sdk": "dart",
/// "flutter_version": "",
/// "has_bloc_lint": false,
/// "has_integration_tests": false,
/// "subpackages": "example example/nested"
/// }
/// ```
class AffectedPackagesCommand extends Command<int> with RepoRootOption {
/// Creates an [AffectedPackagesCommand].
AffectedPackagesCommand() {
addRepoRootOption();
argParser
..addOption(
'base',
help: 'Base git ref to compare against.',
defaultsTo: 'origin/main',
)
..addOption(
'head',
help: 'Head git ref to compare.',
defaultsTo: 'HEAD',
)
..addOption(
'sdk',
help: 'Filter by SDK type.',
allowed: ['dart', 'flutter'],
)
..addFlag(
'all',
help: 'List all packages, not just affected ones.',
);
}
@override
String get name => 'affected_packages';
@override
String get description => 'List packages affected by changes';
@override
Future<int> run() async {
final result = affectedPackagesMetadata(
repoRoot: Directory(repoRoot),
baseRef: argResults!['base'] as String,
headRef: argResults!['head'] as String,
sdkFilter: argResults!['sdk'] as String?,
all: argResults!.flag('all'),
);
if (result.isEmpty) {
// Distinguish "no packages in repo" from "no affected packages".
// We can't tell from here without re-analyzing, so just output [].
stdout.writeln('[]');
return 0;
}
stdout.writeln(jsonEncode(result));
return 0;
}
}
@@ -0,0 +1,5 @@
export 'affected_packages_command.dart';
export 'flutter_version_command.dart';
export 'generate_command.dart';
export 'update_actions_command.dart';
export 'verify_command.dart';
@@ -0,0 +1,46 @@
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:shorebird_ci/src/flutter_version_resolver.dart';
/// Resolves the Flutter version from pubspec environment constraints.
///
/// This command exists because `subosito/flutter-action` only accepts exact
/// version strings — it cannot resolve constraints like `>=3.19.0 <4.0.0`
/// from the pubspec `environment.flutter` field.
///
/// Ideally this would be unnecessary: the Flutter ecosystem should provide a
/// standard way to resolve "which Flutter version satisfies my pubspec?" and
/// actions like `subosito/flutter-action` should consume it natively. If that
/// happens, this command should be deprecated in favor of the upstream
/// solution.
class FlutterVersionCommand extends Command<int> {
/// Creates a [FlutterVersionCommand].
FlutterVersionCommand() {
argParser.addOption(
'pubspec',
help: 'Path to the package directory containing pubspec.yaml.',
defaultsTo: '.',
);
}
@override
String get name => 'flutter_version';
@override
String get description => 'Resolve Flutter version from pubspec';
@override
Future<int> run() async {
final packagePath = argResults!['pubspec'] as String;
final version = resolveFlutterVersionOrStable(packagePath: packagePath);
stdout.writeln(version);
if (version == 'stable') {
stderr.writeln(
'No exact Flutter version found in pubspec.yaml, '
'using stable.',
);
}
return 0;
}
}
@@ -0,0 +1,685 @@
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_ci/src/commands/repo_root_option.dart';
import 'package:shorebird_ci/src/dependency_resolver.dart';
import 'package:shorebird_ci/src/flutter_version_resolver.dart';
import 'package:shorebird_ci/src/package_description.dart';
import 'package:shorebird_ci/src/repository_analyzer.dart';
import 'package:shorebird_ci/src/repository_description.dart';
/// Generates a GitHub Actions CI workflow for a Dart/Flutter repository.
class GenerateCommand extends Command<int> with RepoRootOption {
/// Creates a [GenerateCommand].
GenerateCommand() {
addRepoRootOption();
argParser
..addOption(
'output',
abbr: 'o',
help: 'Output file path.',
defaultsTo: '.github/workflows/shorebird_ci.yaml',
)
..addOption(
'style',
help: 'Workflow style to generate.',
allowed: ['dynamic', 'static'],
defaultsTo: 'dynamic',
)
..addFlag(
'dry-run',
help:
'Print the generated workflow to stdout '
'instead of writing to a file.',
);
}
@override
String get name => 'generate';
@override
String get description => 'Generate a GitHub Actions CI workflow';
@override
Future<int> run() async {
final outputPath = argResults!['output'] as String;
final dryRun = argResults!.flag('dry-run');
final style = argResults!['style'] as String;
final analyzer = RepositoryAnalyzer();
final repoDir = Directory(repoRoot);
final repository = analyzer.analyze(
repositoryRoot: repoDir,
);
if (repository.packages.isEmpty) {
stderr.writeln('No Dart packages found in $repoRoot');
return 1;
}
// Each builder returns a map of repo-relative path → file content.
// Dynamic returns a single entry; static returns a main workflow
// plus one or two reusable workflows.
//
// 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)};
if (dryRun) {
final sortedKeys = files.keys.toList()..sort();
for (final key in sortedKeys) {
stdout
..writeln('# ──── $key ────')
..write(files[key])
..writeln();
}
return 0;
}
for (final entry in files.entries) {
final outputFile = File(p.join(repoRoot, entry.key));
outputFile.parent.createSync(recursive: true);
outputFile.writeAsStringSync(entry.value);
stderr.writeln('Wrote ${outputFile.path}');
}
_ensureDependabotConfig(repoRoot);
return 0;
}
/// Creates `.github/dependabot.yml` with a `github-actions` ecosystem
/// entry if the file doesn't already exist.
void _ensureDependabotConfig(String repoRoot) {
final file = File(p.join(repoRoot, '.github', 'dependabot.yml'));
if (file.existsSync()) {
final content = file.readAsStringSync();
if (!content.contains('github-actions')) {
stderr.writeln(
'Note: .github/dependabot.yml exists but has no '
'github-actions entry. Consider adding one so action '
'versions in your workflow stay up to date.',
);
}
return;
}
file.parent.createSync(recursive: true);
file.writeAsStringSync('''
# Dependabot auto-updates action versions in your workflows.
# https://docs.github.com/en/code-security/dependabot
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
gha-deps:
patterns:
- "*"
''');
stderr.writeln('Wrote ${file.path}');
}
// ── Static (dorny/paths-filter + reusable workflows) ─────────────
//
// Produces a full drop-in workflow setup: a main workflow that uses
// dorny/paths-filter to pick which packages are affected and a thin
// per-package job that calls a reusable workflow. The reusable
// workflow(s) hold the actual CI steps so we don't duplicate them
// across N named jobs.
Map<String, String> _buildStaticFiles(
RepositoryDescription repository, {
required String outputPath,
}) {
final packages = repository.packages.toList()
..sort(
(PackageDescription a, PackageDescription b) =>
a.name.compareTo(b.name),
);
final hasDart = packages.any(
(pkg) => !RepositoryAnalyzer.dependsOnFlutter(root: pkg.root),
);
final hasFlutter = packages.any(
(pkg) => RepositoryAnalyzer.dependsOnFlutter(root: pkg.root),
);
final files = <String, String>{
outputPath: _buildStaticMainYaml(
repository: repository,
packages: packages,
),
};
if (hasDart) {
files['.github/workflows/_shorebird_ci_dart.yaml'] =
_buildDartReusableWorkflow(hasCodecov: repository.hasCodecov);
}
if (hasFlutter) {
files['.github/workflows/_shorebird_ci_flutter.yaml'] =
_buildFlutterReusableWorkflow(hasCodecov: repository.hasCodecov);
}
return files;
}
String _buildStaticMainYaml({
required RepositoryDescription repository,
required List<PackageDescription> packages,
}) {
final resolver = DependencyResolver(repository.root.path);
final buffer = StringBuffer()
..write('''
# Generated by shorebird_ci --style static. Safe to edit.
# Run `shorebird_ci verify` to check for dep graph drift.
name: Shorebird CI
on:
pull_request:
branches:
- main
push:
branches:
- main
jobs:
changes:
runs-on: ubuntu-latest
outputs:
''');
for (final package in packages) {
buffer.writeln(
' ${package.name}: '
'\${{ steps.filter.outputs.${package.name} }}',
);
}
// Verify first so we fail fast if the dorny filters below have
// drifted from the Dart dep graph. Without this, a new package or
// a changed `path:` dependency would silently go uncovered.
buffer.write('''
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
- run: dart pub global activate shorebird_ci
- name: Verify CI coverage
run: shorebird_ci verify
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
''');
for (final package in packages) {
final packageDir = posixRelative(
package.rootPath,
from: repository.root.path,
);
final sortedDeps = resolver.resolve(packageDir).toList()..sort();
buffer.writeln(' ${package.name}:');
for (final dep in sortedDeps) {
buffer.writeln(' - $dep/**');
}
}
buffer.writeln();
for (final package in packages) {
final packageDir = posixRelative(
package.rootPath,
from: repository.root.path,
);
final isFlutter = RepositoryAnalyzer.dependsOnFlutter(
root: package.root,
);
final subpackages =
RepositoryAnalyzer.subpackages(package: package)
.map((sub) => posixRelative(sub.rootPath, from: package.rootPath))
.toList()
..sort();
final reusable = isFlutter
? '_shorebird_ci_flutter.yaml'
: '_shorebird_ci_dart.yaml';
buffer.write('''
${package.name}:
needs: changes
if: needs.changes.outputs.${package.name} == 'true'
uses: ./.github/workflows/$reusable
with:
package_name: ${package.name}
package_path: $packageDir
has_bloc_lint: ${RepositoryAnalyzer.dependsOnBlocLint(root: package.root)}
subpackages: "${subpackages.join(' ')}"
''');
if (isFlutter) {
final version = resolveFlutterVersion(
packagePath: package.rootPath,
);
buffer
..writeln(' flutter_version: "${version ?? ''}"')
..writeln(
' has_integration_tests: '
'${RepositoryAnalyzer.hasIntegrationTests(root: package.root)}',
);
}
buffer.writeln();
}
if (repository.cspellConfig != null) {
_writeCspellJob(buffer, repository);
}
return buffer.toString();
}
String _buildDartReusableWorkflow({required bool hasCodecov}) {
final testStep = hasCodecov
? r'''
- name: Run Tests
working-directory: ${{ inputs.package_path }}
run: |
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
- uses: codecov/codecov-action@v5
with:
flags: ${{ inputs.package_name }}
working-directory: ${{ inputs.package_path }}
'''
: r'''
- working-directory: ${{ inputs.package_path }}
run: dart test
''';
return '''
# Generated by shorebird_ci --style static. Safe to edit.
# Reusable workflow: CI steps for a single Dart package.
on:
workflow_call:
inputs:
package_name:
required: true
type: string
package_path:
required: true
type: string
has_bloc_lint:
required: false
default: false
type: boolean
subpackages:
required: false
default: ""
type: string
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: dart-lang/setup-dart@v1
- name: Setup Bloc Tools
if: inputs.has_bloc_lint
uses: felangel/setup-bloc-tools@v0
- name: Install Dependencies
working-directory: \${{ inputs.package_path }}
run: |
dart pub get --no-example
for sub in \${{ inputs.subpackages }}; do
dart pub get --no-example -C \$sub
done
- working-directory: \${{ inputs.package_path }}
run: dart format --set-exit-if-changed .
- working-directory: \${{ inputs.package_path }}
run: dart analyze .
- name: Bloc Lint
if: inputs.has_bloc_lint
working-directory: \${{ inputs.package_path }}
run: bloc lint .
$testStep''';
}
String _buildFlutterReusableWorkflow({required bool hasCodecov}) {
final testStep = hasCodecov
? r'''
- working-directory: ${{ inputs.package_path }}
run: flutter test --coverage
- uses: codecov/codecov-action@v5
with:
flags: ${{ inputs.package_name }}
working-directory: ${{ inputs.package_path }}
'''
: r'''
- working-directory: ${{ inputs.package_path }}
run: flutter test
''';
return '''
# Generated by shorebird_ci --style static. Safe to edit.
# Reusable workflow: CI steps for a single Flutter package.
on:
workflow_call:
inputs:
package_name:
required: true
type: string
package_path:
required: true
type: string
flutter_version:
required: false
default: ""
type: string
has_bloc_lint:
required: false
default: false
type: boolean
has_integration_tests:
required: false
default: false
type: boolean
subpackages:
required: false
default: ""
type: string
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: \${{ inputs.flutter_version || '' }}
channel: \${{ inputs.flutter_version && '' || 'stable' }}
- name: Setup Bloc Tools
if: inputs.has_bloc_lint
uses: felangel/setup-bloc-tools@v0
- name: Install Dependencies
working-directory: \${{ inputs.package_path }}
run: |
flutter pub get --no-example
for sub in \${{ inputs.subpackages }}; do
flutter pub get --no-example -C \$sub
done
- working-directory: \${{ inputs.package_path }}
run: dart format --set-exit-if-changed .
- working-directory: \${{ inputs.package_path }}
run: flutter analyze .
- name: Bloc Lint
if: inputs.has_bloc_lint
working-directory: \${{ inputs.package_path }}
run: bloc lint .
$testStep - name: Integration Tests
if: inputs.has_integration_tests
working-directory: \${{ inputs.package_path }}
run: flutter test integration_test
''';
}
// ── Dynamic (affected_packages + matrix) ─────────────────────────
String _buildDynamicYaml(RepositoryDescription repository) {
final hasDart = repository.packages.any(
(pkg) => !RepositoryAnalyzer.dependsOnFlutter(root: pkg.root),
);
final hasFlutter = repository.packages.any(
(pkg) => RepositoryAnalyzer.dependsOnFlutter(root: pkg.root),
);
final buffer = StringBuffer()
..write('''
# Generated by shorebird_ci. Safe to edit.
# Run `shorebird_ci verify` to check for dep graph drift.
# shorebird_ci-managed: dynamic
name: Shorebird CI
on:
pull_request:
branches:
- main
push:
branches:
- main
jobs:
''');
_writeSetupJob(buffer, hasDart: hasDart, hasFlutter: hasFlutter);
if (hasDart) {
_writeCiJob(
buffer,
jobId: 'dart_ci',
outputKey: 'dart_packages',
sdk: 'dart',
hasCodecov: repository.hasCodecov,
);
}
if (hasFlutter) {
_writeCiJob(
buffer,
jobId: 'flutter_ci',
outputKey: 'flutter_packages',
sdk: 'flutter',
hasCodecov: repository.hasCodecov,
);
}
if (repository.cspellConfig != null) {
_writeCspellJob(buffer, repository);
}
return buffer.toString();
}
void _writeSetupJob(
StringBuffer buffer, {
required bool hasDart,
required bool hasFlutter,
}) {
buffer.write('''
setup:
runs-on: ubuntu-latest
outputs:
''');
if (hasDart) {
buffer.writeln(
r' dart_packages: ${{ steps.affected.outputs.dart_packages }}',
);
}
if (hasFlutter) {
buffer.writeln(
' flutter_packages: '
r'${{ steps.affected.outputs.flutter_packages }}',
);
}
buffer.write('''
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: dart-lang/setup-dart@v1
- run: dart pub global activate shorebird_ci
# Verify first so we fail fast if CI coverage is broken and
# don't waste time computing affected packages.
- name: Verify CI coverage
run: shorebird_ci verify
- id: affected
run: |
''');
if (hasDart) {
buffer.write(r'''
DART=$(shorebird_ci affected_packages --sdk dart)
echo "dart_packages=$DART" >> $GITHUB_OUTPUT
''');
}
if (hasFlutter) {
buffer.write(r'''
FLUTTER=$(shorebird_ci affected_packages --sdk flutter)
echo "flutter_packages=$FLUTTER" >> $GITHUB_OUTPUT
''');
}
buffer.writeln();
}
void _writeCiJob(
StringBuffer buffer, {
required String jobId,
required String outputKey,
required String sdk,
required bool hasCodecov,
}) {
final isFlutter = sdk == 'flutter';
final executable = isFlutter ? 'flutter' : 'dart';
buffer
..write(_ciJobHeader(jobId: jobId, outputKey: outputKey))
..write(_sdkSetupStep(isFlutter: isFlutter))
..write(_blocToolsSetupStep)
..write(_installDependenciesStep(executable: executable))
..write(_formatAndAnalyzeSteps(executable: executable))
..write(
_testStep(
isFlutter: isFlutter,
hasCodecov: hasCodecov,
executable: executable,
),
);
if (isFlutter) buffer.write(_integrationTestsStep);
if (hasCodecov) buffer.write(_codecovUploadStep);
buffer.writeln();
}
// The job header up through the shared `actions/checkout` step.
String _ciJobHeader({required String jobId, required String outputKey}) {
return '''
$jobId:
needs: setup
if: needs.setup.outputs.$outputKey != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include: \${{ fromJSON(needs.setup.outputs.$outputKey) }}
name: \${{ matrix.name }}
defaults:
run:
working-directory: \${{ matrix.path }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
''';
}
// For Flutter, use `flutter_version` from the matrix if the pubspec
// pins an exact version; otherwise fall back to `channel: stable`.
String _sdkSetupStep({required bool isFlutter}) {
if (!isFlutter) return ' - uses: dart-lang/setup-dart@v1\n';
return r'''
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ matrix.flutter_version || '' }}
channel: ${{ matrix.flutter_version && '' || 'stable' }}
''';
}
static const _blocToolsSetupStep = '''
- name: Setup Bloc Tools
if: matrix.has_bloc_lint == true
uses: felangel/setup-bloc-tools@v0
''';
String _installDependenciesStep({required String executable}) {
return '''
- name: Install Dependencies
run: |
$executable pub get --no-example
for sub in \${{ matrix.subpackages }}; do
$executable pub get --no-example -C \$sub
done
''';
}
String _formatAndAnalyzeSteps({required String executable}) {
return '''
- run: dart format --set-exit-if-changed .
- run: $executable analyze .
- name: Bloc Lint
if: matrix.has_bloc_lint == true
run: bloc lint .
''';
}
String _testStep({
required bool isFlutter,
required bool hasCodecov,
required String executable,
}) {
if (!hasCodecov) return ' - run: $executable test\n';
if (isFlutter) return ' - run: flutter test --coverage\n';
return r'''
- name: Run Tests
run: |
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
''';
}
static const _integrationTestsStep = '''
- name: Integration Tests
if: matrix.has_integration_tests == true
run: flutter test integration_test
''';
static const _codecovUploadStep = r'''
- uses: codecov/codecov-action@v5
with:
flags: ${{ matrix.name }}
''';
void _writeCspellJob(
StringBuffer buffer,
RepositoryDescription repository,
) {
final configPath = posixRelative(
repository.cspellConfig!.path,
from: repository.root.path,
);
buffer
..write('''
cspell:
name: CSpell
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: streetsidesoftware/cspell-action@v6
with:
incremental_files_only: false
config: $configPath
''')
..writeln();
}
}
@@ -0,0 +1,17 @@
import 'package:args/command_runner.dart';
/// Adds a `--repo-root` option to a command and exposes it via
/// [repoRoot]. Default is `'.'` to match the convention used by every
/// other command in this package.
mixin RepoRootOption on Command<int> {
/// Call from the command's constructor to register the option.
void addRepoRootOption() {
argParser.addOption(
'repo-root',
help: 'Path to the repository root.',
);
}
/// The resolved repo root, or `'.'` if not specified.
String get repoRoot => argResults!['repo-root'] as String? ?? '.';
}
@@ -0,0 +1,77 @@
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:shorebird_ci/src/action_versions.dart';
/// Updates GitHub Actions `uses:` versions to the current latest major
/// in one or more workflow files.
///
/// Scans each workflow file for `uses: owner/repo@vN` references,
/// queries GitHub's API for the latest major version, and rewrites the
/// file in place. Works on any workflow file, not just ones generated
/// by `shorebird_ci`.
class UpdateActionsCommand extends Command<int> {
/// Creates an [UpdateActionsCommand]. The optional [resolveLatestMajor]
/// is exposed for tests so they don't have to hit the live GitHub API.
UpdateActionsCommand({LatestMajorResolver? resolveLatestMajor})
: _resolveLatestMajor = resolveLatestMajor {
argParser.addOption(
'workflow-dir',
help: 'Directory containing workflow YAML files to update.',
defaultsTo: '.github/workflows',
);
}
final LatestMajorResolver? _resolveLatestMajor;
@override
String get name => 'update_actions';
@override
String get description => 'Update GitHub Actions versions in workflow files';
@override
Future<int> run() async {
final workflowDir = Directory(
argResults!['workflow-dir'] as String,
);
if (!workflowDir.existsSync()) {
stderr.writeln('No such directory: ${workflowDir.path}');
return 1;
}
final files =
workflowDir
.listSync()
.whereType<File>()
.where((f) => f.path.endsWith('.yaml') || f.path.endsWith('.yml'))
.toList()
..sort((a, b) => a.path.compareTo(b.path));
if (files.isEmpty) {
stderr.writeln('No workflow files found in ${workflowDir.path}');
return 0;
}
var updatedCount = 0;
for (final file in files) {
final before = file.readAsStringSync();
final after = await updateActionVersions(
before,
resolveLatestMajor: _resolveLatestMajor,
);
if (before != after) {
file.writeAsStringSync(after);
stdout.writeln('Updated: ${file.path}');
updatedCount++;
}
}
stdout.writeln(
updatedCount == 0
? 'All action versions are up to date.'
: '\nUpdated $updatedCount workflow(s).',
);
return 0;
}
}
@@ -0,0 +1,158 @@
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_ci/src/commands/repo_root_option.dart';
import 'package:shorebird_ci/src/dependency_resolver.dart';
import 'package:shorebird_ci/src/dorny_filter.dart';
import 'package:shorebird_ci/src/package_description.dart';
import 'package:shorebird_ci/src/repository_analyzer.dart';
/// Marker comment that the `generate` command writes into dynamic
/// workflows. Verify uses this to detect dynamic coverage instead of
/// substring-searching for the call (which falsely matches comments,
/// `run: echo "..."`, and similar).
const dynamicCoverageMarker = '# shorebird_ci-managed: dynamic';
/// Verifies that every discovered package has CI coverage somewhere in
/// `.github/workflows/`.
///
/// Coverage can be provided in two ways:
/// - **Dynamic**: a workflow that calls `shorebird_ci affected_packages`
/// covers every package automatically. One dynamic workflow means no
/// missing packages.
/// - **Static**: each package name appears in a `dorny/paths-filter`
/// block somewhere. Missing packages are reported with the dorny
/// entry that should be added (including transitive deps).
class VerifyCommand extends Command<int> with RepoRootOption {
/// Creates a [VerifyCommand].
VerifyCommand() {
addRepoRootOption();
argParser.addOption(
'ignore',
help: 'Comma-separated list of package names to ignore.',
);
}
@override
String get name => 'verify';
@override
String get description => 'Verify every package has CI coverage';
@override
Future<int> run() async {
final ignoreStr = argResults!['ignore'] as String?;
final ignoreSet = ignoreStr != null
? ignoreStr.split(',').map((s) => s.trim()).toSet()
: <String>{};
final analyzer = RepositoryAnalyzer();
final repository = analyzer.analyze(
repositoryRoot: Directory(repoRoot),
);
final workflowDir = Directory(
p.join(repoRoot, '.github', 'workflows'),
);
if (!workflowDir.existsSync()) {
stderr.writeln('No .github/workflows directory found.');
return 1;
}
// Map each package name to the workflow(s) that cover it.
final coverageMap = <String, List<String>>{};
final workflowFiles =
workflowDir
.listSync()
.whereType<File>()
.where((f) => f.path.endsWith('.yaml'))
.toList()
..sort((a, b) => a.path.compareTo(b.path));
final dynamicWorkflows = <String>[];
for (final file in workflowFiles) {
final content = file.readAsStringSync();
final fileName = p.basename(file.path);
if (_usesDynamicCoverage(content)) {
dynamicWorkflows.add(fileName);
continue;
}
final names = extractDornyFilterNames(content);
for (final name in names) {
coverageMap.putIfAbsent(name, () => []).add(fileName);
}
}
final allPackages = repository.packages.toList()
..sort((a, b) => a.name.compareTo(b.name));
if (dynamicWorkflows.isNotEmpty) {
stdout.writeln(
'Using dynamic coverage via ${dynamicWorkflows.join(', ')}'
'all ${allPackages.length} packages covered at runtime.',
);
return 0;
}
final missing = <PackageDescription>[];
for (final pkg in allPackages) {
if (ignoreSet.contains(pkg.name)) continue;
final workflows = coverageMap[pkg.name];
if (workflows != null) {
stdout.writeln(
'OK: ${pkg.name} (${workflows.join(', ')})',
);
} else {
missing.add(pkg);
}
}
if (missing.isEmpty) {
stdout.writeln('\nAll packages have CI coverage.');
return 0;
}
stdout.writeln();
final resolver = DependencyResolver(repoRoot);
for (final pkg in missing) {
final packageDir = posixRelative(pkg.rootPath, from: repoRoot);
final deps = resolver.resolve(packageDir);
final sortedDeps = deps.toList()..sort();
stdout
..writeln('MISSING: ${pkg.name}')
..writeln(' Add this entry to a dorny paths-filter block:')
..writeln(' ${pkg.name}:');
for (final dep in sortedDeps) {
stdout.writeln(' - $dep/**');
}
stdout.writeln();
}
stderr.writeln(
'${missing.length} package(s) missing from CI coverage.',
);
return 1;
}
/// Whether a workflow uses the dynamic affected_packages approach.
///
/// Looks for the marker comment that the `generate` command writes.
/// Substring-searching for `shorebird_ci affected_packages` would
/// also match YAML comments, commented-out code, and `run: echo ...`
/// strings — the marker is unambiguous.
///
/// The marker is plain text (a YAML comment), so editing the file
/// keeps the marker in place by default. A user who removes the
/// marker without also removing the dynamic call loses coverage and
/// `verify` fails — which is the right failure mode.
bool _usesDynamicCoverage(String workflowContent) {
return workflowContent.contains(dynamicCoverageMarker);
}
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:path/path.dart' as p;
/// Candidate paths for cSpell config files.
///
/// See https://cspell.org/docs/getting-started#1-create-a-configuration-file
final cSpellConfigFileNames = {
'.cspell.json',
'cspell.json',
'.cSpell.json',
'cSpell.json',
'.cspell.jsonc',
'cspell.jsonc',
'.cspell.yaml',
'cspell.yaml',
'.cspell.yml',
'cspell.yml',
'.cspell.config.json',
'cspell.config.json',
'.cspell.config.jsonc',
'cspell.config.jsonc',
'.cspell.config.yaml',
'cspell.config.yaml',
'.cspell.config.yml',
'cspell.config.yml',
p.join('.config', '.cspell.json'),
p.join('.config', 'cspell.json'),
p.join('.config', 'cspell.yaml'),
p.join('.config', 'cspell.yml'),
p.join('.vscode', '.cspell.json'),
p.join('.vscode', 'cSpell.json'),
p.join('.vscode', 'cspell.json'),
};
@@ -0,0 +1,54 @@
import 'package:path/path.dart' as p;
import 'package:shorebird_ci/src/pubspec.dart';
import 'package:yaml/yaml.dart';
/// Resolves transitive path dependencies for Dart packages in a repository.
///
/// Understands both `path:` dependencies and Dart workspace
/// `resolution: workspace` members.
class DependencyResolver {
/// Creates a resolver rooted at [repoRoot].
DependencyResolver(this.repoRoot);
/// The absolute path to the repository root.
final String repoRoot;
/// Returns all transitive path dependency directories (repo-relative)
/// for the package at [packageDir].
Set<String> resolve(String packageDir) {
final visited = <String>{};
final queue = <String>[packageDir];
while (queue.isNotEmpty) {
final current = queue.removeAt(0);
if (visited.contains(current)) continue;
visited.add(current);
final deps = _getPathDependencies(current);
queue.addAll(deps.where((d) => !visited.contains(d)));
}
return visited;
}
List<String> _getPathDependencies(String packageDir) {
final pubspec = readPubspec(p.join(repoRoot, packageDir));
final dependencies = pubspec?['dependencies'] as YamlMap?;
if (dependencies == null) return const [];
final deps = <String>[];
for (final entry in dependencies.entries) {
if (entry.value is YamlMap) {
final pathValue = (entry.value as YamlMap)['path'];
if (pathValue != null) {
final resolved = p.normalize(
p.join(packageDir, pathValue as String),
);
deps.add(resolved);
}
}
}
return deps;
}
}
@@ -0,0 +1,49 @@
/// Extracts package/filter names from dorny/paths-filter blocks in a
/// workflow YAML file.
///
/// Looks for `filters: |` blocks and extracts the top-level keys
/// (which are the filter names that become job outputs).
Set<String> extractDornyFilterNames(String workflowContent) {
final names = <String>{};
final filterBlockRegex = RegExp(r'filters:\s*\|');
for (final match in filterBlockRegex.allMatches(workflowContent)) {
final afterBlock = workflowContent.substring(match.end);
final lines = afterBlock.split('\n');
// Detect the indentation of the first filter name.
int? filterIndent;
for (final line in lines) {
if (line.trim().isEmpty) continue;
final leadingSpaces = line.length - line.trimLeft().length;
if (filterIndent == null) {
// First non-blank line sets the expected indentation.
final filterName = RegExp(r'^(\s+)(\w[\w-]*):\s*$').firstMatch(line);
if (filterName == null) break;
filterIndent = leadingSpaces;
names.add(filterName.group(2)!);
continue;
}
if (leadingSpaces == filterIndent) {
final filterName = RegExp(r'^\s+(\w[\w-]*):\s*$').firstMatch(line);
if (filterName != null) {
names.add(filterName.group(1)!);
continue;
}
break;
}
// Deeper indentation = path entries, skip them.
if (leadingSpaces > filterIndent) continue;
// Less indentation = left the filter block.
break;
}
}
return names;
}
@@ -0,0 +1,30 @@
import 'package:pub_semver/pub_semver.dart';
import 'package:shorebird_ci/src/pubspec.dart';
import 'package:yaml/yaml.dart';
/// Given a path to a Flutter package, attempts to determine the Flutter
/// version that should be used to build the package.
///
/// Checks the `environment.flutter` field in pubspec.yaml. If an exact
/// version is specified, returns it. If a version constraint is found,
/// returns `null` (constraints are not supported — callers should fall
/// back to `stable`). If no version is found, returns `null`.
String? resolveFlutterVersion({required String packagePath}) {
final pubspec = readPubspec(packagePath);
final environment = pubspec?['environment'] as YamlMap?;
final flutterVersionString = environment?['flutter'] as String?;
if (flutterVersionString == null) return null;
final version = VersionConstraint.parse(flutterVersionString);
if (version is Version) return version.toString();
// It's a constraint (e.g., ">=3.19.0 <4.0.0"), not an exact version.
return null;
}
/// Like [resolveFlutterVersion] but falls back to `'stable'` when no
/// exact version is pinned. This is what the `flutter_version` CLI
/// emits — it always produces something `subosito/flutter-action` can
/// accept.
String resolveFlutterVersionOrStable({required String packagePath}) =>
resolveFlutterVersion(packagePath: packagePath) ?? 'stable';
+77
View File
@@ -0,0 +1,77 @@
import 'dart:io';
/// Runs git commands via [Process.runSync].
///
/// All calls are synchronous. We never run multiple git commands in
/// parallel and Dart's sync IO is faster than its async IO when you're
/// just going to await the result anyway.
class Git {
/// Creates a [Git] instance.
const Git();
/// Returns the list of files changed between [base] and [head].
List<String> changedFiles({
required String base,
required String head,
required String workingDirectory,
}) {
final args = ['diff', '--name-only', base, head];
final result = Process.runSync(
'git',
args,
workingDirectory: workingDirectory,
);
if (result.exitCode != 0) {
throw ProcessException('git', args, '${result.stderr}', result.exitCode);
}
return (result.stdout as String)
.split('\n')
.where((line) => line.isNotEmpty)
.toList();
}
/// Returns the paths of all git submodules.
List<String> submodulePaths({required String workingDirectory}) {
final result = Process.runSync(
'git',
['submodule', 'status'],
workingDirectory: workingDirectory,
);
if (result.exitCode != 0) return [];
return parseSubmoduleStatus(result.stdout as String);
}
/// Returns whether the given [path] is ignored by git.
bool isIgnored({required String path, required String workingDirectory}) {
final result = Process.runSync(
'git',
['check-ignore', '-q', path],
workingDirectory: workingDirectory,
);
return result.exitCode == 0;
}
}
/// Parses the output of `git submodule status` and returns the
/// submodule paths.
///
/// Each non-empty line has the form:
/// ```text
/// <flag><sha> <path>[ (<describe-output>)]
/// ```
/// where `<flag>` is one of space (initialized), `-` (uninitialized),
/// `+` (commit doesn't match index), `U` (merge conflicts).
/// `<path>` may contain spaces. `(<describe-output>)` is optional and
/// only present when the submodule is initialized.
List<String> parseSubmoduleStatus(String output) {
// Match the leading flag + SHA, capture the path, then strip an
// optional trailing ` (...)` describe-output. Captures everything
// between the SHA and the optional ` (` so paths with spaces work.
final lineRe = RegExp(r'^[ +\-U][0-9a-f]+\s+(.+?)(?:\s+\([^)]*\))?$');
return output
.split('\n')
.where((line) => line.isNotEmpty)
.map((line) => lineRe.firstMatch(line)?.group(1))
.whereType<String>()
.toList();
}
@@ -0,0 +1,33 @@
import 'dart:io';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
/// A description of a Dart package discovered in a repository.
@immutable
class PackageDescription {
/// Creates a [PackageDescription].
const PackageDescription({required this.name, required this.rootPath});
/// The name of the package from pubspec.yaml.
final String name;
/// The absolute path to the root directory of the package.
final String rootPath;
/// The root directory of the package.
Directory get root => Directory(rootPath);
/// Whether the given [path] is within the package's directory structure.
bool containsPath(String path) => p.isWithin(rootPath, path);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PackageDescription &&
name == other.name &&
rootPath == other.rootPath;
@override
int get hashCode => Object.hash(name, rootPath);
}
@@ -0,0 +1,20 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart';
/// Reads and parses `pubspec.yaml` from [packageDir].
///
/// Returns `null` if the file doesn't exist, doesn't parse as YAML, or
/// the top-level value isn't a map (e.g., the file is malformed or
/// has been replaced with a list).
YamlMap? readPubspec(String packageDir) {
final file = File(p.join(packageDir, 'pubspec.yaml'));
if (!file.existsSync()) return null;
try {
final yaml = loadYaml(file.readAsStringSync());
return yaml is YamlMap ? yaml : null;
} on Exception {
return null;
}
}
@@ -0,0 +1,432 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:shorebird_ci/src/codecov.dart';
import 'package:shorebird_ci/src/cspell.dart';
import 'package:shorebird_ci/src/git.dart';
import 'package:shorebird_ci/src/package_description.dart';
import 'package:shorebird_ci/src/pubspec.dart';
import 'package:shorebird_ci/src/repository_description.dart';
import 'package:shorebird_ci/src/workspace.dart' as workspace;
import 'package:yaml/yaml.dart';
/// Analyzes a repository to discover its Dart packages and structure.
class RepositoryAnalyzer {
/// Creates a [RepositoryAnalyzer].
RepositoryAnalyzer({Git? git}) : _git = git ?? const Git();
final Git _git;
/// Generates a [RepositoryDescription] for the given [repositoryRoot].
RepositoryDescription analyze({required Directory repositoryRoot}) {
if (!repositoryRoot.existsSync()) {
throw FileSystemException(
'Repository root does not exist',
repositoryRoot.path,
);
}
final submodulePaths = _git.submodulePaths(
workingDirectory: repositoryRoot.path,
);
final absoluteSubmodulePaths = submodulePaths
.map((e) => p.join(repositoryRoot.path, e))
.toSet();
// One walk of the repo, pruning into submodules and nested git
// repos/worktrees. Collects pubspecs, codecov configs, and cspell
// configs in a single pass.
final pubspecFiles = <File>[];
var hasCodecov = false;
File? cSpellConfigFile;
walkPruned(
root: repositoryRoot,
shouldPruneDirectory: (dir) =>
_isUnderSubmodule(dir.path, absoluteSubmodulePaths) ||
_hasNestedGit(dir),
onFile: (entry) {
if (entry.path.endsWith('pubspec.yaml')) {
pubspecFiles.add(entry);
}
final relPath = p.relative(
entry.path,
from: repositoryRoot.path,
);
if (!hasCodecov && codecovFileNames.contains(relPath)) {
hasCodecov = true;
}
if (cSpellConfigFile == null &&
cSpellConfigFileNames.contains(relPath)) {
cSpellConfigFile = entry;
}
},
);
final packages = _filterPackages(
pubspecFiles: pubspecFiles,
repositoryRoot: repositoryRoot,
);
final packageDescriptions = packages
.map((pubspec) {
final name = packageName(root: pubspec.parent);
if (name == null) return null;
_requireSafePackageName(name, source: pubspec.path);
final relative = posixRelative(
pubspec.parent.path,
from: repositoryRoot.path,
);
_requireSafePath(relative);
return PackageDescription(
name: name,
rootPath: pubspec.parent.path,
);
})
.whereType<PackageDescription>()
.toList();
_checkForDuplicateNames(packageDescriptions);
return RepositoryDescription(
packages: packageDescriptions,
root: repositoryRoot,
hasCodecov: hasCodecov,
cspellConfig: cSpellConfigFile,
);
}
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,
/// digits, `_`, `-`, `.`, and `/`. Any other character (whitespace,
/// shell metacharacters, Unicode) is rejected.
///
/// The generated workflow word-splits matrix entries via
/// `for sub in ${{ matrix.subpackages }}; do ... done` — a path with
/// shell metacharacters would otherwise be injected directly into
/// the runner's shell. Restricting paths to a portable POSIX subset
/// makes the simple loop safe by construction.
static final _safePathRegex = RegExp(r'^[A-Za-z0-9._/-]+$');
static void _requireSafePath(String path) {
if (_safePathRegex.hasMatch(path)) return;
throw FormatException(
'shorebird_ci requires package paths to be portable POSIX paths '
'using only letters, digits, `_`, `-`, `.`, and `/`. The '
'generated CI workflow embeds these paths in shell commands and '
'cannot tolerate whitespace, shell metacharacters, or Unicode. '
'Got: $path',
);
}
/// Pub's own naming convention for packages: lowercase letter or
/// underscore start, then lowercase letters, digits, and underscores.
/// `pubspec.yaml` is just YAML — pub doesn't gate this name until you
/// publish — so a malformed name can land here and get embedded as
/// a YAML map key in the generated workflow. Validate at analysis
/// time instead.
static final _safePackageNameRegex = RegExp(r'^[a-z][a-z0-9_]*$');
static void _requireSafePackageName(String name, {required String source}) {
if (_safePackageNameRegex.hasMatch(name)) return;
throw FormatException(
'Invalid package name in $source: "$name". Package names must '
'start with a lowercase letter and contain only lowercase '
'letters, digits, and underscores '
'(see https://dart.dev/tools/pub/pubspec#name).',
);
}
static bool _isUnderSubmodule(String path, Set<String> submodulePaths) {
for (final submodule in submodulePaths) {
if (path == submodule) return true;
if (path.startsWith('$submodule${p.separator}')) return true;
}
return false;
}
static bool _hasNestedGit(Directory dir) {
// Nested git repos and worktrees both have a `.git` entry at their
// root: a directory for standalone repos, a file for worktrees.
final gitPath = p.join(dir.path, '.git');
return File(gitPath).existsSync() || Directory(gitPath).existsSync();
}
/// Identifies the packages affected by changes between [baseRef] and
/// [headRef], including transitive dependents.
Set<PackageDescription> affectedPackages({
required RepositoryDescription repository,
required String baseRef,
required String headRef,
}) {
final changedFiles = _git.changedFiles(
base: baseRef,
head: headRef,
workingDirectory: repository.root.path,
);
final packages = repository.packages;
final directlyChanged = packages
.where(
(package) => changedFiles.any(
(file) => package.containsPath(
p.normalize(p.join(repository.root.path, file)),
),
),
)
.toSet();
final targets = {...directlyChanged};
for (final package in packages) {
final deps = _recursivePackageDependencies(
repository: repository,
package: package,
);
if (deps.intersection(directlyChanged).isNotEmpty) {
targets.add(package);
}
}
return targets;
}
List<File> _filterPackages({
required List<File> pubspecFiles,
required Directory repositoryRoot,
}) {
final packages = <File>[];
for (final pubspec in pubspecFiles) {
try {
if (_git.isIgnored(
path: pubspec.path,
workingDirectory: repositoryRoot.path,
)) {
continue;
}
} on ProcessException {
continue;
}
if (workspace.isWorkspaceStubRoot(pubspec.parent.path)) continue;
packages.add(pubspec);
}
return packages;
}
/// The name of the package at the given [root].
static String? packageName({required Directory root}) {
return readPubspec(root.path)?['name'] as String?;
}
/// Whether the package at the given [root] is a Flutter package.
static bool isFlutterPackage({required Directory root}) {
final pubspec = readPubspec(root.path);
if (pubspec == null) return false;
final dependencies = pubspec['dependencies'] as YamlMap?;
final flutter = dependencies?['flutter'] as YamlMap?;
return flutter != null &&
flutter.containsKey('sdk') &&
flutter['sdk'] == 'flutter';
}
/// Whether the package at the given [root] depends on Flutter.
static bool dependsOnFlutter({required Directory root}) {
if (isFlutterPackage(root: root)) return true;
final pubspec = readPubspec(root.path);
if (pubspec == null) return false;
final environment = pubspec['environment'] as YamlMap?;
if (environment != null && environment.containsKey('flutter')) {
return true;
}
if (workspace.usesWorkspaceResolution(root.path)) {
final workspaceRoot = workspace.findWorkspaceRoot(root.path);
if (workspaceRoot == null) return true;
// Defensive: a pubspec that declares both `workspace:` and
// `resolution: workspace` would resolve to itself and recurse
// forever. Already checked the local pubspec above, so return.
if (workspaceRoot.path == root.path) return false;
return dependsOnFlutter(root: workspaceRoot);
}
return false;
}
/// Whether the package at the given [root] depends on `bloc_lint`.
static bool dependsOnBlocLint({required Directory root}) {
final pubspec = readPubspec(root.path);
if (pubspec == null) return false;
final devDependencies = pubspec['dev_dependencies'] as YamlMap?;
return devDependencies?.containsKey('bloc_lint') ?? false;
}
/// Whether a package has unit tests.
static bool hasUnitTests({required Directory root}) {
return _hasAnyFile(Directory(p.join(root.path, 'test')));
}
/// Whether a package has integration tests (Flutter only).
static bool hasIntegrationTests({required Directory root}) {
if (!isFlutterPackage(root: root)) return false;
return _hasAnyFile(Directory(p.join(root.path, 'integration_test')));
}
static bool _hasAnyFile(Directory dir) {
if (!dir.existsSync()) return false;
var found = false;
walkPruned(
root: dir,
shouldPruneDirectory: _hasNestedGit,
onFile: (_) => found = true,
);
return found;
}
/// Packages nested within [package]'s directory structure.
///
/// Skips nested git repos and worktrees so that vendored packages,
/// build outputs containing pubspec.yaml, and Claude Code worktrees
/// don't appear as subpackages.
///
/// Each subpackage's relative path is validated to be shell-safe
/// (see [_requireSafePath]); a path outside `[A-Za-z0-9._/-]+` is
/// a generation-time error rather than a CI-time injection.
static Iterable<PackageDescription> subpackages({
required PackageDescription package,
}) {
final found = <PackageDescription>[];
walkPruned(
root: package.root,
// Skip the parent package's own pubspec; only return descendants.
shouldPruneDirectory: (dir) =>
dir.path != package.rootPath && _hasNestedGit(dir),
onFile: (file) {
if (!file.path.endsWith('pubspec.yaml')) return;
if (p.equals(file.parent.path, package.rootPath)) return;
final name = packageName(root: file.parent);
if (name == null) return;
final relative = posixRelative(
file.parent.path,
from: package.rootPath,
);
_requireSafePath(relative);
found.add(
PackageDescription(name: name, rootPath: file.parent.path),
);
},
);
return found;
}
static Set<PackageDescription> _recursivePackageDependencies({
required RepositoryDescription repository,
required PackageDescription package,
}) {
final ret = <PackageDescription>{};
final toVisit = {package};
while (toVisit.isNotEmpty) {
final current = toVisit.first;
toVisit.remove(current);
final dependencies = _localDependencies(
repository: repository,
package: current,
);
final newDependencies = dependencies.difference(ret);
ret.addAll(newDependencies);
toVisit.addAll(newDependencies);
}
return ret;
}
static Set<PackageDescription> _localDependencies({
required RepositoryDescription repository,
required PackageDescription package,
}) {
final pubspec = readPubspec(package.root.path);
if (pubspec == null) return {};
final usesWorkspace = pubspec['resolution'] == 'workspace';
final dependencies = pubspec['dependencies'] as YamlMap?;
if (dependencies == null) return {};
return dependencies.entries
.map((entry) {
// Workspace packages can reference local deps by name alone.
if (usesWorkspace) {
final byName = repository.packages
.where((p) => p.name == entry.key)
.firstOrNull;
if (byName != null) return byName;
}
if (entry.value is! YamlMap) return null;
final depPath = (entry.value as YamlMap)['path'] as String?;
if (depPath == null) return null;
final normalizedPath = p.canonicalize(
p.join(package.root.path, depPath),
);
return repository.packages
.where(
(pkg) => p.canonicalize(pkg.root.path) == normalizedPath,
)
.firstOrNull;
})
.whereType<PackageDescription>()
.toSet();
}
}
/// Walks the directory tree rooted at [root], invoking [onFile] for
/// every regular file. Directories for which [shouldPruneDirectory]
/// returns true are not descended into. Symbolic links are not followed.
void walkPruned({
required Directory root,
required bool Function(Directory dir) shouldPruneDirectory,
required void Function(File file) onFile,
}) {
void visit(Directory dir) {
for (final entry in dir.listSync(followLinks: false)) {
if (entry is Directory) {
if (shouldPruneDirectory(entry)) continue;
visit(entry);
} else if (entry is File) {
onFile(entry);
}
}
}
visit(root);
}
/// Like [p.relative], but always returns a POSIX-style path with
/// forward-slash separators. Use at every boundary where a relative
/// path leaves Dart for YAML, shell, or a Linux runner — `p.relative`
/// emits `\` on Windows, which would land in the generated workflow as
/// a path that the runner can't resolve.
String posixRelative(String path, {required String from}) {
return p.relative(path, from: from).replaceAll(r'\', '/');
}
@@ -0,0 +1,26 @@
import 'dart:io';
import 'package:shorebird_ci/src/package_description.dart';
/// A description of a Dart/Flutter repository's structure.
class RepositoryDescription {
/// Creates a [RepositoryDescription].
RepositoryDescription({
required this.packages,
required this.root,
required this.hasCodecov,
this.cspellConfig,
});
/// The packages in the repository.
final List<PackageDescription> packages;
/// The root directory of the repository.
final Directory root;
/// Whether the repository has codecov configured.
final bool hasCodecov;
/// The cspell configuration file, if one exists.
final File? cspellConfig;
}
@@ -0,0 +1,15 @@
import 'package:args/command_runner.dart';
import 'package:shorebird_ci/src/commands/commands.dart';
/// The shorebird_ci command runner.
class ShorebirdCiCommandRunner extends CommandRunner<int> {
/// Creates a [ShorebirdCiCommandRunner].
ShorebirdCiCommandRunner()
: super('shorebird_ci', 'CI tooling for Dart/Flutter monorepos') {
addCommand(AffectedPackagesCommand());
addCommand(FlutterVersionCommand());
addCommand(GenerateCommand());
addCommand(UpdateActionsCommand());
addCommand(VerifyCommand());
}
}
@@ -0,0 +1,52 @@
import 'dart:io';
import 'package:shorebird_ci/src/pubspec.dart';
/// Workspace detection for Dart packages.
///
/// A Dart workspace is a pubspec.yaml that lists members under a
/// `workspace:` key. Members opt in by setting `resolution: workspace`
/// in their own pubspec, after which their dependencies are resolved
/// against the workspace root rather than per-package.
/// Whether the pubspec at [packageDir] declares a workspace via a
/// non-empty `workspace:` list.
bool isDartWorkspace(String packageDir) {
final pubspec = readPubspec(packageDir);
if (pubspec == null) return false;
final workspace = pubspec['workspace'];
return workspace is List && workspace.isNotEmpty;
}
/// Whether the pubspec at [packageDir] is a workspace "stub" root
/// that should be skipped during package discovery.
///
/// Convention: a workspace root with no name or a name starting with
/// `_` (e.g., shorebird's `name: _`) is just a grouping mechanism
/// and isn't itself a package to test.
bool isWorkspaceStubRoot(String packageDir) {
if (!isDartWorkspace(packageDir)) return false;
final name = readPubspec(packageDir)?['name'] as String?;
return name == null || name.startsWith('_');
}
/// Whether the pubspec at [packageDir] uses workspace resolution
/// (`resolution: workspace`).
bool usesWorkspaceResolution(String packageDir) {
final pubspec = readPubspec(packageDir);
return pubspec?['resolution'] == 'workspace';
}
/// Walks up from [packageDir] looking for the nearest ancestor whose
/// pubspec.yaml declares a non-empty `workspace:` list. Returns the
/// containing directory, or `null` if no workspace root is found.
Directory? findWorkspaceRoot(String packageDir) {
var dir = Directory(packageDir);
Directory? prev;
while (prev?.path != dir.path) {
if (isDartWorkspace(dir.path)) return dir;
prev = dir;
dir = dir.parent;
}
return null;
}
+20
View File
@@ -0,0 +1,20 @@
name: shorebird_ci
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.1.0
resolution: workspace
environment:
sdk: ^3.9.0
dependencies:
args: ^2.6.0
meta: ^1.16.0
path: ^1.9.0
pub_semver: ^2.1.0
yaml: ^3.1.0
dev_dependencies:
test: ^1.25.0
@@ -0,0 +1,71 @@
import 'package:shorebird_ci/src/action_versions.dart';
import 'package:test/test.dart';
// Note: updateActionVersions hits GitHub's API to resolve latest
// versions. These tests only cover the paths that don't require
// network — content without any `uses:` lines, or content that matches
// nothing that would trigger a lookup.
void main() {
group('updateActionVersions', () {
test('returns unchanged content with no `uses:` references', () async {
const input = '''
name: test
jobs:
hello:
runs-on: ubuntu-latest
steps:
- run: echo hi
''';
expect(await updateActionVersions(input), equals(input));
});
test('leaves local action references alone', () async {
// Local actions (`uses: ./...`) aren't on GitHub, so the
// resolver shouldn't try to look them up.
const input = '''
steps:
- uses: ./.github/actions/my_local_action
''';
expect(await updateActionVersions(input), equals(input));
});
test('leaves SHA pins alone', () async {
// `@<40-char hash>` isn't a version tag we'd bump.
// cspell:disable-next-line
const input = '''
steps:
- uses: actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
''';
expect(await updateActionVersions(input), equals(input));
});
test('leaves branch-pinned actions alone', () async {
const input = '''
steps:
- uses: actions/checkout@main
''';
expect(await updateActionVersions(input), equals(input));
});
test('only rewrites uses: lines, not comments or run: strings', () async {
// Regression: a previous version used String.replaceAll, which
// would also rewrite `actions/checkout@v4` inside comments and
// `run: echo "..."` strings.
const input = '''
# Pinned to actions/checkout@v4 — see release notes
steps:
- uses: actions/checkout@v4
- run: echo "we use actions/checkout@v4 here"
''';
final result = await updateActionVersions(
input,
resolveLatestMajor: (repo) async => 'v5',
);
// Only the uses: line gets bumped; comment and echo keep v4.
expect(result, contains('# Pinned to actions/checkout@v4'));
expect(result, contains('echo "we use actions/checkout@v4 here"'));
expect(result, contains('uses: actions/checkout@v5'));
});
});
}
@@ -0,0 +1,175 @@
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_ci/src/affected_packages.dart';
import 'package:shorebird_ci/src/commands/commands.dart';
import 'package:test/test.dart';
import 'test_utils.dart';
void main() {
setUpTempDir('shorebird_ci_affected_');
test('rejects package paths with shell metacharacters', () async {
// Regression: the generated workflow word-splits matrix entries
// via `for sub in ${{ matrix.subpackages }}` — a path containing
// shell metacharacters (e.g. an attacker-controlled subpackage
// name) would otherwise execute as code on the runner. Reject
// these paths in the analyzer instead so the simple shell loop is
// safe by construction.
createPackage(tempDir, r'packages/$(echo pwned)', 'pwned');
initGitRepo(tempDir);
expect(
() => affectedPackagesMetadata(repoRoot: tempDir, all: true),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains('portable POSIX paths'),
),
),
);
});
test('--all lists every package with metadata', () async {
createPackage(tempDir, 'packages/foo', 'foo');
createPackage(tempDir, 'packages/bar', 'bar', flutterLine: 'any');
initGitRepo(tempDir);
final result = affectedPackagesMetadata(
repoRoot: tempDir,
all: true,
);
expect(result, hasLength(2));
final names = result.map((e) => e['name']).toSet();
expect(names, equals({'foo', 'bar'}));
final fooEntry = result.firstWhere((e) => e['name'] == 'foo');
expect(fooEntry['sdk'], equals('dart'));
expect(fooEntry['path'], equals('packages/foo'));
expect(fooEntry['has_bloc_lint'], isFalse);
expect(fooEntry['has_integration_tests'], isFalse);
expect(fooEntry['subpackages'], equals(''));
final barEntry = result.firstWhere((e) => e['name'] == 'bar');
expect(barEntry['sdk'], equals('flutter'));
});
test('--sdk dart filters out flutter packages', () async {
createPackage(tempDir, 'packages/dart_pkg', 'dart_pkg');
createPackage(
tempDir,
'packages/flutter_pkg',
'flutter_pkg',
flutterLine: 'any',
);
initGitRepo(tempDir);
final result = affectedPackagesMetadata(
repoRoot: tempDir,
all: true,
sdkFilter: 'dart',
);
expect(result, hasLength(1));
expect(result.first['name'], equals('dart_pkg'));
});
test('--sdk flutter filters out dart packages', () async {
createPackage(tempDir, 'packages/dart_pkg', 'dart_pkg');
createPackage(
tempDir,
'packages/flutter_pkg',
'flutter_pkg',
flutterLine: 'any',
);
initGitRepo(tempDir);
final result = affectedPackagesMetadata(
repoRoot: tempDir,
all: true,
sdkFilter: 'flutter',
);
expect(result, hasLength(1));
expect(result.first['name'], equals('flutter_pkg'));
});
test('empty repo returns empty list', () async {
initGitRepo(tempDir);
final result = affectedPackagesMetadata(
repoRoot: tempDir,
all: true,
);
expect(result, isEmpty);
});
test('without --all uses git diff to compute affected packages', () async {
createPackage(tempDir, 'packages/foo', 'foo');
createPackage(tempDir, 'packages/bar', 'bar');
initGitRepo(tempDir);
File(
p.join(tempDir.path, 'packages/foo/change.txt'),
).writeAsStringSync('x');
commitAll(tempDir, 'change foo');
final result = affectedPackagesMetadata(
repoRoot: tempDir,
baseRef: 'HEAD~1',
headRef: 'HEAD',
);
expect(result.map((e) => e['name']).toSet(), equals({'foo'}));
});
test('uses default base/head refs when none provided', () async {
// Exercises the `?? 'origin/main'` and `?? 'HEAD'` defaults.
// origin/main isn't set up in this temp repo, so the underlying
// `git diff` will fail; we expect that failure to surface (which
// confirms the default refs are wired in).
createPackage(tempDir, 'packages/foo', 'foo');
initGitRepo(tempDir);
expect(
() => affectedPackagesMetadata(repoRoot: tempDir),
throwsA(isA<ProcessException>()),
);
});
// Smoke test of the CLI wrapper — exercises arg parsing and the
// empty-result path. The data-building logic is tested directly
// above against the helper.
test('command exits 0 on a repo with no packages', () async {
initGitRepo(tempDir);
final runner = CommandRunner<int>('test', 'test')
..addCommand(AffectedPackagesCommand());
final code = await runner.run([
'affected_packages',
'--repo-root',
tempDir.path,
'--all',
]);
expect(code, 0);
});
test('command emits JSON when --all finds packages', () async {
// Exercises the `stdout.writeln(jsonEncode(result))` branch of run().
createPackage(tempDir, 'packages/foo', 'foo');
initGitRepo(tempDir);
final runner = CommandRunner<int>('test', 'test')
..addCommand(AffectedPackagesCommand());
final code = await runner.run([
'affected_packages',
'--repo-root',
tempDir.path,
'--all',
]);
expect(code, 0);
});
test('command exposes a non-empty description', () {
expect(AffectedPackagesCommand().description, isNotEmpty);
});
}
@@ -0,0 +1,100 @@
import 'package:shorebird_ci/shorebird_ci.dart';
import 'package:test/test.dart';
import 'test_utils.dart';
void main() {
setUpTempDir('shorebird_ci_dep_resolver_');
group('DependencyResolver', () {
test('resolves direct path dependencies', () {
createPackage(tempDir, 'packages/package_b', 'package_b');
createPackage(
tempDir,
'packages/package_a',
'package_a',
dependencies: {'package_b': '../package_b'},
);
final resolver = DependencyResolver(tempDir.path);
final deps = resolver.resolve('packages/package_a');
expect(
deps,
containsAll(['packages/package_a', 'packages/package_b']),
);
});
test('resolves transitive path dependencies', () {
createPackage(tempDir, 'packages/package_c', 'package_c');
createPackage(
tempDir,
'packages/package_b',
'package_b',
dependencies: {'package_c': '../package_c'},
);
createPackage(
tempDir,
'packages/package_a',
'package_a',
dependencies: {'package_b': '../package_b'},
);
final resolver = DependencyResolver(tempDir.path);
final deps = resolver.resolve('packages/package_a');
expect(
deps,
containsAll([
'packages/package_a',
'packages/package_b',
'packages/package_c',
]),
);
});
test('handles packages with no dependencies', () {
createPackage(tempDir, 'packages/standalone', 'standalone');
final resolver = DependencyResolver(tempDir.path);
final deps = resolver.resolve('packages/standalone');
expect(deps, equals({'packages/standalone'}));
});
test('handles cycles without looping forever', () {
createPackage(
tempDir,
'packages/a',
'a',
dependencies: {'b': '../b'},
);
createPackage(
tempDir,
'packages/b',
'b',
dependencies: {'a': '../a'},
);
final resolver = DependencyResolver(tempDir.path);
final deps = resolver.resolve('packages/a');
expect(deps, equals({'packages/a', 'packages/b'}));
});
test('handles packages at non-standard paths', () {
createPackage(tempDir, 'libs/util', 'util');
createPackage(
tempDir,
'apps/server',
'server',
dependencies: {'util': '../../libs/util'},
);
final resolver = DependencyResolver(tempDir.path);
final deps = resolver.resolve('apps/server');
expect(deps, containsAll(['apps/server', 'libs/util']));
});
});
}
@@ -0,0 +1,122 @@
import 'package:shorebird_ci/shorebird_ci.dart';
import 'package:test/test.dart';
void main() {
group('extractDornyFilterNames', () {
test('extracts filter names from a standard dorny block', () {
const workflow = '''
jobs:
changes:
steps:
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
foo:
- packages/foo/**
bar:
- packages/bar/**
- packages/shared/**
''';
expect(
extractDornyFilterNames(workflow),
equals({'foo', 'bar'}),
);
});
test('returns empty set when no dorny block', () {
const workflow = '''
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
''';
expect(extractDornyFilterNames(workflow), isEmpty);
});
test('extracts from multiple dorny blocks', () {
const workflow = '''
jobs:
changes:
steps:
- uses: dorny/paths-filter@v3
id: first
with:
filters: |
alpha:
- packages/alpha/**
- uses: dorny/paths-filter@v3
id: second
with:
filters: |
beta:
- packages/beta/**
''';
expect(
extractDornyFilterNames(workflow),
equals({'alpha', 'beta'}),
);
});
test('handles non-standard indentation', () {
// Same structure but with different (larger) nesting indent.
const workflow = '''
on:
push:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: dorny/paths-filter@v3
with:
filters: |
foo:
- packages/foo/**
bar:
- packages/bar/**
''';
expect(
extractDornyFilterNames(workflow),
equals({'foo', 'bar'}),
);
});
test('stops at end of filter block', () {
const workflow = '''
- uses: dorny/paths-filter@v3
with:
filters: |
foo:
- packages/foo/**
- name: Other step
run: echo done
''';
expect(extractDornyFilterNames(workflow), equals({'foo'}));
});
test('ignores deeply indented content (path entries)', () {
const workflow = '''
- uses: dorny/paths-filter@v3
with:
filters: |
foo:
- packages/foo/**
- packages/shared/**
bar:
- packages/bar/**
''';
// Should extract foo and bar, not the path entries.
expect(
extractDornyFilterNames(workflow),
equals({'foo', 'bar'}),
);
});
});
}
@@ -0,0 +1,45 @@
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_ci/src/commands/commands.dart';
import 'package:test/test.dart';
import 'test_utils.dart';
Future<int?> runFlutterVersion(Directory pkgDir) async {
final runner = CommandRunner<int>('test', 'test')
..addCommand(FlutterVersionCommand());
return runner.run([
'flutter_version',
'--pubspec',
pkgDir.path,
]);
}
void main() {
setUpTempDir('shorebird_ci_flutter_version_command_');
test('exits 0 when an exact version is pinned', () async {
File(p.join(tempDir.path, 'pubspec.yaml')).writeAsStringSync('''
name: test
environment:
sdk: ^3.0.0
flutter: 3.22.1
''');
expect(await runFlutterVersion(tempDir), 0);
});
test('exits 0 when no version is pinned (falls back to stable)', () async {
File(p.join(tempDir.path, 'pubspec.yaml')).writeAsStringSync('''
name: test
environment:
sdk: ^3.0.0
''');
expect(await runFlutterVersion(tempDir), 0);
});
test('exposes a non-empty description', () {
expect(FlutterVersionCommand().description, isNotEmpty);
});
}
@@ -0,0 +1,88 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:shorebird_ci/shorebird_ci.dart';
import 'package:test/test.dart';
import 'test_utils.dart';
void main() {
setUpTempDir('shorebird_ci_flutter_version_');
void writePubspec(String content) {
File(p.join(tempDir.path, 'pubspec.yaml')).writeAsStringSync(content);
}
group('resolveFlutterVersion', () {
test('returns exact version from environment.flutter', () {
writePubspec('''
name: test
environment:
sdk: ^3.0.0
flutter: 3.22.1
''');
expect(
resolveFlutterVersion(packagePath: tempDir.path),
equals('3.22.1'),
);
});
test('returns null for version constraints', () {
writePubspec('''
name: test
environment:
sdk: ^3.0.0
flutter: ">=3.19.0 <4.0.0"
''');
expect(resolveFlutterVersion(packagePath: tempDir.path), isNull);
});
test('returns null when environment.flutter is absent', () {
writePubspec('''
name: test
environment:
sdk: ^3.0.0
''');
expect(resolveFlutterVersion(packagePath: tempDir.path), isNull);
});
test('returns null when pubspec.yaml is missing', () {
expect(resolveFlutterVersion(packagePath: tempDir.path), isNull);
});
test('returns null when pubspec.yaml is not a map', () {
writePubspec('- just a list');
expect(resolveFlutterVersion(packagePath: tempDir.path), isNull);
});
});
group('resolveFlutterVersionOrStable', () {
test('returns the exact version when pinned', () {
writePubspec('''
name: test
environment:
sdk: ^3.0.0
flutter: 3.22.1
''');
expect(
resolveFlutterVersionOrStable(packagePath: tempDir.path),
equals('3.22.1'),
);
});
test('returns "stable" when nothing pinned', () {
writePubspec('''
name: test
environment:
sdk: ^3.0.0
''');
expect(
resolveFlutterVersionOrStable(packagePath: tempDir.path),
equals('stable'),
);
});
});
}
@@ -0,0 +1,323 @@
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_ci/src/commands/commands.dart';
import 'package:test/test.dart';
import 'test_utils.dart';
/// Runs `generate` via the command runner.
Future<int?> runGenerate(
Directory repoRoot, {
List<String> extra = const [],
}) async {
final runner = CommandRunner<int>('test', 'test')
..addCommand(GenerateCommand());
return runner.run([
'generate',
'--repo-root',
repoRoot.path,
...extra,
]);
}
void main() {
setUpTempDir('shorebird_ci_generate_');
group('generate (dynamic, default)', () {
test('emits a single workflow file', () async {
createPackage(tempDir, 'packages/foo', 'foo');
initGitRepo(tempDir);
await runGenerate(tempDir);
final mainYaml = File(
p.join(tempDir.path, '.github', 'workflows', 'shorebird_ci.yaml'),
);
expect(mainYaml.existsSync(), isTrue);
// No static reusables when dynamic.
expect(
File(
p.join(
tempDir.path,
'.github',
'workflows',
'_shorebird_ci_dart.yaml',
),
).existsSync(),
isFalse,
);
});
test('Dart-only repo gets only dart_ci job', () async {
createPackage(tempDir, 'packages/foo', 'foo');
initGitRepo(tempDir);
await runGenerate(tempDir);
final yaml = _readMain(tempDir);
expect(yaml, contains('dart_ci:'));
expect(yaml, isNot(contains('flutter_ci:')));
});
test('Flutter-only repo gets only flutter_ci job', () async {
createPackage(
tempDir,
'packages/foo',
'foo',
flutterLine: 'any',
);
initGitRepo(tempDir);
await runGenerate(tempDir);
final yaml = _readMain(tempDir);
expect(yaml, contains('flutter_ci:'));
expect(yaml, isNot(contains('dart_ci:')));
});
test('mixed repo gets both dart_ci and flutter_ci', () async {
createPackage(tempDir, 'packages/dart_pkg', 'dart_pkg');
createPackage(
tempDir,
'packages/flutter_pkg',
'flutter_pkg',
flutterLine: 'any',
);
initGitRepo(tempDir);
await runGenerate(tempDir);
final yaml = _readMain(tempDir);
expect(yaml, contains('dart_ci:'));
expect(yaml, contains('flutter_ci:'));
});
test('codecov detected → adds codecov upload step', () 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);
expect(yaml, contains('codecov/codecov-action'));
expect(yaml, contains('coverage:format_coverage'));
});
test('no codecov → no coverage steps', () async {
createPackage(tempDir, 'packages/foo', 'foo', addTestDir: true);
initGitRepo(tempDir);
await runGenerate(tempDir);
final yaml = _readMain(tempDir);
expect(yaml, isNot(contains('codecov/codecov-action')));
expect(yaml, isNot(contains('coverage:format_coverage')));
});
test('cspell config detected → adds cspell job', () async {
createPackage(tempDir, 'packages/foo', 'foo');
File(p.join(tempDir.path, '.cspell.json')).writeAsStringSync('{}');
initGitRepo(tempDir);
await runGenerate(tempDir);
final yaml = _readMain(tempDir);
expect(yaml, contains('cspell:'));
expect(yaml, contains('streetsidesoftware/cspell-action'));
});
test('returns 1 when no packages discovered', () async {
initGitRepo(tempDir);
final code = await runGenerate(tempDir);
expect(code, 1);
});
test('--dry-run does not write any files', () async {
createPackage(tempDir, 'packages/foo', 'foo');
initGitRepo(tempDir);
await runGenerate(tempDir, extra: ['--dry-run']);
// Neither the workflow nor dependabot.yml should be on disk.
expect(
File(
p.join(
tempDir.path,
'.github',
'workflows',
'shorebird_ci.yaml',
),
).existsSync(),
isFalse,
);
expect(
File(
p.join(tempDir.path, '.github', 'dependabot.yml'),
).existsSync(),
isFalse,
);
});
// Invariants: if the generator drops these strings, verify in the
// generated workflow would silently misbehave at CI time. Catch
// it here instead.
test('output contains the markers verify recognizes', () async {
createPackage(tempDir, 'packages/foo', 'foo');
initGitRepo(tempDir);
await runGenerate(tempDir);
final yaml = _readMain(tempDir);
// verify recognizes dynamic coverage by looking for this string.
expect(yaml, contains('shorebird_ci affected_packages'));
// The verify step must itself be present in the generated workflow.
expect(yaml, contains('shorebird_ci verify'));
});
});
group('generate --style static', () {
test('emits main + dart reusable for Dart-only repo', () async {
createPackage(tempDir, 'packages/foo', 'foo');
initGitRepo(tempDir);
await runGenerate(tempDir, extra: ['--style', 'static']);
final workflows = p.join(tempDir.path, '.github', 'workflows');
expect(
File(p.join(workflows, 'shorebird_ci.yaml')).existsSync(),
isTrue,
);
expect(
File(p.join(workflows, '_shorebird_ci_dart.yaml')).existsSync(),
isTrue,
);
// No flutter reusable when there are no flutter packages.
expect(
File(p.join(workflows, '_shorebird_ci_flutter.yaml')).existsSync(),
isFalse,
);
});
test('emits main + both reusables for mixed repo', () async {
createPackage(tempDir, 'packages/dart_pkg', 'dart_pkg');
createPackage(
tempDir,
'packages/flutter_pkg',
'flutter_pkg',
flutterLine: 'any',
);
initGitRepo(tempDir);
await runGenerate(tempDir, extra: ['--style', 'static']);
final workflows = p.join(tempDir.path, '.github', 'workflows');
expect(
File(p.join(workflows, '_shorebird_ci_dart.yaml')).existsSync(),
isTrue,
);
expect(
File(p.join(workflows, '_shorebird_ci_flutter.yaml')).existsSync(),
isTrue,
);
});
test(
'main workflow uses dorny/paths-filter and per-package jobs',
() async {
createPackage(tempDir, 'packages/foo', 'foo');
createPackage(
tempDir,
'packages/bar',
'bar',
dependencies: {'foo': '../foo'},
);
initGitRepo(tempDir);
await runGenerate(tempDir, extra: ['--style', 'static']);
final yaml = _readMain(tempDir);
expect(yaml, contains('dorny/paths-filter'));
expect(yaml, contains('foo:'));
expect(yaml, contains('bar:'));
// bar depends on foo, so bar's filter should include foo's path.
expect(
yaml,
matches(RegExp(r'bar:[^#]*packages/foo/\*\*', dotAll: true)),
);
},
);
test('verify step comes before dorny filter', () async {
createPackage(tempDir, 'packages/foo', 'foo');
initGitRepo(tempDir);
await runGenerate(tempDir, extra: ['--style', 'static']);
final yaml = _readMain(tempDir);
final verifyIdx = yaml.indexOf('shorebird_ci verify');
final dornyIdx = yaml.indexOf('dorny/paths-filter');
expect(verifyIdx, greaterThan(-1));
expect(dornyIdx, greaterThan(-1));
expect(verifyIdx, lessThan(dornyIdx));
});
test('cspell config detected → adds cspell job in static mode', () async {
createPackage(tempDir, 'packages/foo', 'foo');
File(p.join(tempDir.path, '.cspell.json')).writeAsStringSync('{}');
initGitRepo(tempDir);
await runGenerate(tempDir, extra: ['--style', 'static']);
final yaml = _readMain(tempDir);
expect(yaml, contains('cspell:'));
expect(yaml, contains('streetsidesoftware/cspell-action'));
});
});
group('dependabot.yml', () {
test('creates dependabot.yml when missing', () async {
createPackage(tempDir, 'packages/foo', 'foo');
initGitRepo(tempDir);
await runGenerate(tempDir);
final dependabotFile = File(
p.join(tempDir.path, '.github', 'dependabot.yml'),
);
expect(dependabotFile.existsSync(), isTrue);
expect(dependabotFile.readAsStringSync(), contains('github-actions'));
});
test('exposes a non-empty description', () {
expect(GenerateCommand().description, isNotEmpty);
});
test('leaves existing dependabot.yml alone', () async {
createPackage(tempDir, 'packages/foo', 'foo');
final githubDir = Directory(p.join(tempDir.path, '.github'))
..createSync(recursive: true);
final existing = File(p.join(githubDir.path, 'dependabot.yml'))
..writeAsStringSync('# user content\nversion: 2\n');
initGitRepo(tempDir);
await runGenerate(tempDir);
expect(
existing.readAsStringSync(),
equals('# user content\nversion: 2\n'),
);
});
});
}
String _readMain(Directory repoRoot) {
return File(
p.join(repoRoot.path, '.github', 'workflows', 'shorebird_ci.yaml'),
).readAsStringSync();
}
+132
View File
@@ -0,0 +1,132 @@
// Test fixtures contain 40-char SHA-style hex strings that trip cspell.
// cspell:disable
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:shorebird_ci/src/git.dart';
import 'package:test/test.dart';
import 'test_utils.dart';
void main() {
group('Git', () {
setUpTempDir('shorebird_ci_git_');
test('changedFiles throws ProcessException when git fails', () {
// No git repo in tempDir → `git diff` exits non-zero.
const git = Git();
expect(
() => git.changedFiles(
base: 'origin/main',
head: 'HEAD',
workingDirectory: tempDir.path,
),
throwsA(isA<ProcessException>()),
);
});
test('changedFiles lists files changed between two refs', () {
// initGitRepo makes the initial commit; add another so HEAD~1
// resolves to the previous commit.
initGitRepo(tempDir);
File(p.join(tempDir.path, 'a.txt')).writeAsStringSync('hi');
commitAll(tempDir, 'add a');
File(p.join(tempDir.path, 'b.txt')).writeAsStringSync('there');
commitAll(tempDir, 'add b');
const git = Git();
final files = git.changedFiles(
base: 'HEAD~1',
head: 'HEAD',
workingDirectory: tempDir.path,
);
expect(files, contains('b.txt'));
});
test('submodulePaths returns [] outside a git repo', () {
const git = Git();
// tempDir is not a git repo here.
expect(
git.submodulePaths(workingDirectory: tempDir.path),
isEmpty,
);
});
test('isIgnored detects gitignored paths', () {
File(p.join(tempDir.path, '.gitignore')).writeAsStringSync('ignored/\n');
initGitRepo(tempDir);
const git = Git();
expect(
git.isIgnored(
path: 'ignored/file.txt',
workingDirectory: tempDir.path,
),
isTrue,
);
expect(
git.isIgnored(
path: 'lib/main.dart',
workingDirectory: tempDir.path,
),
isFalse,
);
});
});
group('parseSubmoduleStatus', () {
test('returns empty list for empty output', () {
expect(parseSubmoduleStatus(''), isEmpty);
});
test('parses a single initialized submodule', () {
// Real `git submodule status` output: leading space (initialized),
// SHA, path, parenthesized describe-output.
const output =
' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa shorebird (heads/main)';
expect(parseSubmoduleStatus(output), equals(['shorebird']));
});
test('parses uninitialized submodule (- prefix, no describe)', () {
const output = '-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa packages/foo';
expect(parseSubmoduleStatus(output), equals(['packages/foo']));
});
test('parses out-of-date submodule (+ prefix)', () {
const output =
'+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa shorebird (heads/main)';
expect(parseSubmoduleStatus(output), equals(['shorebird']));
});
test('parses merge-conflict submodule (U prefix)', () {
const output = 'Uaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa shorebird';
expect(parseSubmoduleStatus(output), equals(['shorebird']));
});
test('handles paths with spaces', () {
const output =
' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa my submodule (heads/main)';
expect(parseSubmoduleStatus(output), equals(['my submodule']));
});
test('parses multiple submodules', () {
const output = '''
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa shorebird (heads/main)
+bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb packages/foo (heads/main)
-cccccccccccccccccccccccccccccccccccccccc packages/bar''';
expect(
parseSubmoduleStatus(output),
equals(['shorebird', 'packages/foo', 'packages/bar']),
);
});
test('skips malformed lines silently', () {
const output = '''
not a submodule line
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa shorebird (heads/main)
also not''';
expect(parseSubmoduleStatus(output), equals(['shorebird']));
});
});
}
@@ -0,0 +1,46 @@
import 'package:shorebird_ci/src/package_description.dart';
import 'package:test/test.dart';
void main() {
group('PackageDescription', () {
const a = PackageDescription(name: 'foo', rootPath: '/repo/foo');
const b = PackageDescription(name: 'foo', rootPath: '/repo/foo');
const differentName = PackageDescription(
name: 'bar',
rootPath: '/repo/foo',
);
const differentPath = PackageDescription(
name: 'foo',
rootPath: '/other/foo',
);
test('is equal to itself', () {
expect(a, equals(a));
});
test('is equal to another instance with the same fields', () {
expect(a, equals(b));
expect(a.hashCode, equals(b.hashCode));
});
test('is not equal when name differs', () {
expect(a == differentName, isFalse);
});
test('is not equal when rootPath differs', () {
expect(a == differentPath, isFalse);
});
test('is not equal to a non-PackageDescription value', () {
// We're deliberately comparing across types to exercise the
// `other is PackageDescription` short-circuit on the == operator.
// ignore: unrelated_type_equality_checks
expect(a == 'foo', isFalse);
});
test('containsPath returns true for paths inside the package root', () {
expect(a.containsPath('/repo/foo/lib/main.dart'), isTrue);
expect(a.containsPath('/repo/bar/lib/main.dart'), isFalse);
});
});
}
@@ -0,0 +1,41 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:shorebird_ci/src/pubspec.dart';
import 'package:test/test.dart';
import 'test_utils.dart';
void main() {
setUpTempDir('shorebird_ci_pubspec_');
test('returns null when pubspec.yaml is missing', () {
expect(readPubspec(tempDir.path), isNull);
});
test('returns null for malformed YAML', () {
File(p.join(tempDir.path, 'pubspec.yaml')).writeAsStringSync(
// Mismatched bracket / unclosed structure that loadYaml rejects.
'name: foo\nbroken: [',
);
expect(readPubspec(tempDir.path), isNull);
});
test('returns null when the top-level value is not a map', () {
File(
p.join(tempDir.path, 'pubspec.yaml'),
).writeAsStringSync('- list\n- yaml');
expect(readPubspec(tempDir.path), isNull);
});
test('parses a valid pubspec', () {
File(p.join(tempDir.path, 'pubspec.yaml')).writeAsStringSync('''
name: foo
environment:
sdk: ^3.0.0
''');
final pubspec = readPubspec(tempDir.path);
expect(pubspec, isNotNull);
expect(pubspec!['name'], equals('foo'));
});
}
@@ -0,0 +1,546 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:shorebird_ci/shorebird_ci.dart';
import 'package:test/test.dart';
import 'test_utils.dart';
/// A fake [Git] that always throws [ProcessException] from
/// [Git.isIgnored]. Used to exercise the defensive catch in
/// `RepositoryAnalyzer._filterPackages`.
class _ThrowingGit extends Git {
const _ThrowingGit();
@override
bool isIgnored({required String path, required String workingDirectory}) {
throw ProcessException('git', ['check-ignore', path], 'boom', 1);
}
}
/// A fake [Git] that returns a fixed list of submodule paths.
class _StubSubmoduleGit extends Git {
const _StubSubmoduleGit({required this.paths});
final List<String> paths;
@override
List<String> submodulePaths({required String workingDirectory}) => paths;
}
void main() {
setUpTempDir('shorebird_ci_analyzer_');
group('RepositoryAnalyzer.analyze', () {
test('discovers packages in a monorepo', () async {
createPackage(tempDir, 'packages/foo', 'foo');
createPackage(tempDir, 'packages/bar', 'bar');
initGitRepo(tempDir);
final analyzer = RepositoryAnalyzer();
final repo = analyzer.analyze(repositoryRoot: tempDir);
final names = repo.packages.map((p) => p.name).toSet();
expect(names, equals({'foo', 'bar'}));
});
test('discovers packages at non-standard paths', () async {
createPackage(tempDir, 'libs/util', 'util');
createPackage(tempDir, 'apps/admin', 'admin');
initGitRepo(tempDir);
final analyzer = RepositoryAnalyzer();
final repo = analyzer.analyze(repositoryRoot: tempDir);
final names = repo.packages.map((p) => p.name).toSet();
expect(names, equals({'util', 'admin'}));
});
test('skips underscore workspace roots', () async {
createPackage(tempDir, 'packages/foo', 'foo', useWorkspace: true);
createWorkspaceRoot(tempDir, members: ['packages/foo']);
initGitRepo(tempDir);
final analyzer = RepositoryAnalyzer();
final repo = analyzer.analyze(repositoryRoot: tempDir);
final names = repo.packages.map((p) => p.name).toSet();
expect(names, equals({'foo'}));
});
test('detects codecov config', () async {
createPackage(tempDir, 'packages/foo', 'foo');
File(
p.join(tempDir.path, 'codecov.yml'),
).writeAsStringSync('coverage:\n status: off');
initGitRepo(tempDir);
final analyzer = RepositoryAnalyzer();
final repo = analyzer.analyze(repositoryRoot: tempDir);
expect(repo.hasCodecov, isTrue);
});
test('detects cspell config', () async {
createPackage(tempDir, 'packages/foo', 'foo');
File(
p.join(tempDir.path, '.cspell.json'),
).writeAsStringSync('{"words": []}');
initGitRepo(tempDir);
final analyzer = RepositoryAnalyzer();
final repo = analyzer.analyze(repositoryRoot: tempDir);
expect(repo.cspellConfig, isNotNull);
expect(p.basename(repo.cspellConfig!.path), equals('.cspell.json'));
});
test('returns no codecov/cspell when absent', () async {
createPackage(tempDir, 'packages/foo', 'foo');
initGitRepo(tempDir);
final analyzer = RepositoryAnalyzer();
final repo = analyzer.analyze(repositoryRoot: tempDir);
expect(repo.hasCodecov, isFalse);
expect(repo.cspellConfig, isNull);
});
test('throws when repo root does not exist', () async {
final missing = Directory(p.join(tempDir.path, 'nope'));
final analyzer = RepositoryAnalyzer();
expect(
() => analyzer.analyze(repositoryRoot: missing),
throwsA(isA<FileSystemException>()),
);
});
test('skips packages whose isIgnored check throws', () async {
// Covers the defensive `on ProcessException → continue` branch
// in _filterPackages: when git is unavailable / returns junk we
// skip the package rather than crashing.
createPackage(tempDir, 'packages/foo', 'foo');
initGitRepo(tempDir);
final analyzer = RepositoryAnalyzer(git: const _ThrowingGit());
final repo = analyzer.analyze(repositoryRoot: tempDir);
// The throwing isIgnored skips every pubspec, so no packages
// make it into the description.
expect(repo.packages, isEmpty);
});
test('throws on package name that violates pub conventions', () async {
// pubspec.yaml is just YAML — pub doesn't gate the name field
// until publish, so a malformed name can land here and end up
// as a YAML map key in the generated workflow. Validate at
// analysis time.
createPackage(tempDir, 'packages/foo', 'Not-Valid');
initGitRepo(tempDir);
final analyzer = RepositoryAnalyzer();
expect(
() => analyzer.analyze(repositoryRoot: tempDir),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains('Invalid package name'),
),
),
);
});
test('throws on duplicate package names', () async {
// Two packages declaring `name: example` would silently collide
// when used as YAML map keys in the generated workflow. Fail
// loudly instead.
createPackage(tempDir, 'a/example', 'example');
createPackage(tempDir, 'b/example', 'example');
initGitRepo(tempDir);
final analyzer = RepositoryAnalyzer();
expect(
() => analyzer.analyze(repositoryRoot: tempDir),
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
contains('Duplicate package names'),
),
),
);
});
test(
'submodule prefix does not silently exclude sibling packages',
() async {
// A submodule at packages/foo must NOT cause packages/foo_bar
// to be skipped — startsWith('packages/foo') would match both.
// We can't easily inject a real submodule into a fresh repo, so
// fake it by using a Git stub that reports a submodule path.
createPackage(tempDir, 'packages/foo', 'foo');
createPackage(tempDir, 'packages/foo_bar', 'foo_bar');
initGitRepo(tempDir);
final analyzer = RepositoryAnalyzer(
git: const _StubSubmoduleGit(paths: ['packages/foo']),
);
final repo = analyzer.analyze(repositoryRoot: tempDir);
// foo (the submodule itself) is skipped; foo_bar must NOT be.
final names = repo.packages.map((p) => p.name).toSet();
expect(names, equals({'foo_bar'}));
},
);
});
group('RepositoryAnalyzer.affectedPackages', () {
test('returns directly changed packages', () async {
createPackage(tempDir, 'packages/foo', 'foo');
createPackage(tempDir, 'packages/bar', 'bar');
initGitRepo(tempDir);
// Modify foo.
File(
p.join(tempDir.path, 'packages/foo/README.md'),
).writeAsStringSync('change');
commitAll(tempDir, 'change');
final analyzer = RepositoryAnalyzer();
final repo = analyzer.analyze(repositoryRoot: tempDir);
final affected = analyzer.affectedPackages(
repository: repo,
baseRef: 'HEAD~1',
headRef: 'HEAD',
);
expect(affected.map((p) => p.name), equals({'foo'}));
});
test('includes transitive dependents', () async {
createPackage(tempDir, 'packages/foo', 'foo');
createPackage(
tempDir,
'packages/bar',
'bar',
dependencies: {'foo': '../foo'},
);
createPackage(
tempDir,
'packages/baz',
'baz',
dependencies: {'bar': '../bar'},
);
initGitRepo(tempDir);
File(
p.join(tempDir.path, 'packages/foo/change.txt'),
).writeAsStringSync('x');
commitAll(tempDir, 'change');
final analyzer = RepositoryAnalyzer();
final repo = analyzer.analyze(repositoryRoot: tempDir);
final affected = analyzer.affectedPackages(
repository: repo,
baseRef: 'HEAD~1',
headRef: 'HEAD',
);
expect(
affected.map((p) => p.name),
equals({'foo', 'bar', 'baz'}),
);
});
test('resolves workspace-named deps', () async {
createPackage(
tempDir,
'packages/foo',
'foo',
useWorkspace: true,
);
createPackage(
tempDir,
'packages/bar',
'bar',
useWorkspace: true,
dependencies: {'foo': '../foo'},
);
createWorkspaceRoot(
tempDir,
members: ['packages/foo', 'packages/bar'],
);
initGitRepo(tempDir);
File(
p.join(tempDir.path, 'packages/foo/change.txt'),
).writeAsStringSync('x');
commitAll(tempDir, 'change');
final analyzer = RepositoryAnalyzer();
final repo = analyzer.analyze(repositoryRoot: tempDir);
final affected = analyzer.affectedPackages(
repository: repo,
baseRef: 'HEAD~1',
headRef: 'HEAD',
);
expect(affected.map((p) => p.name), equals({'foo', 'bar'}));
});
});
group('RepositoryAnalyzer static helpers', () {
test('isFlutterPackage', () async {
final dir = Directory(p.join(tempDir.path, 'flutter_pkg'));
createPackage(
tempDir,
'flutter_pkg',
'flutter_pkg',
flutterLine: 'any',
);
expect(RepositoryAnalyzer.isFlutterPackage(root: dir), isTrue);
});
test('dependsOnFlutter via environment constraint', () async {
final dir = Directory(p.join(tempDir.path, 'pkg'));
createPackage(
tempDir,
'pkg',
'pkg',
flutterLine: '>=3.19.0',
);
expect(RepositoryAnalyzer.dependsOnFlutter(root: dir), isTrue);
expect(RepositoryAnalyzer.isFlutterPackage(root: dir), isFalse);
});
test('hasUnitTests detects non-empty test directory', () async {
createPackage(
tempDir,
'pkg',
'pkg',
addTestDir: true,
);
final dir = Directory(p.join(tempDir.path, 'pkg'));
expect(RepositoryAnalyzer.hasUnitTests(root: dir), isTrue);
});
test('hasUnitTests returns false when no test directory', () async {
createPackage(tempDir, 'pkg', 'pkg');
final dir = Directory(p.join(tempDir.path, 'pkg'));
expect(RepositoryAnalyzer.hasUnitTests(root: dir), isFalse);
});
test(
'dependsOnFlutter inherits via workspace resolution',
() async {
// The workspace root depends on Flutter via environment.
File(p.join(tempDir.path, 'pubspec.yaml')).writeAsStringSync('''
name: _
environment:
sdk: ^3.0.0
flutter: "3.22.1"
workspace:
- packages/foo
''');
// The member has no Flutter dep of its own — only `resolution:
// workspace`. dependsOnFlutter should walk up to the workspace
// root and find Flutter there.
createPackage(
tempDir,
'packages/foo',
'foo',
useWorkspace: true,
);
final fooDir = Directory(p.join(tempDir.path, 'packages/foo'));
expect(RepositoryAnalyzer.dependsOnFlutter(root: fooDir), isTrue);
},
);
test('hasIntegrationTests', () async {
createPackage(
tempDir,
'flutter_pkg',
'flutter_pkg',
flutterLine: 'any',
);
final dir = Directory(p.join(tempDir.path, 'flutter_pkg'));
// No integration_test directory yet.
expect(RepositoryAnalyzer.hasIntegrationTests(root: dir), isFalse);
Directory(p.join(dir.path, 'integration_test')).createSync();
File(
p.join(dir.path, 'integration_test', 'app_test.dart'),
).writeAsStringSync('void main() {}');
expect(RepositoryAnalyzer.hasIntegrationTests(root: dir), isTrue);
});
test('hasIntegrationTests is false for non-flutter packages', () async {
createPackage(tempDir, 'dart_pkg', 'dart_pkg');
final dir = Directory(p.join(tempDir.path, 'dart_pkg'));
Directory(p.join(dir.path, 'integration_test')).createSync();
File(
p.join(dir.path, 'integration_test', 'app_test.dart'),
).writeAsStringSync('void main() {}');
// Even with the directory present, dart-only packages are skipped.
expect(RepositoryAnalyzer.hasIntegrationTests(root: dir), isFalse);
});
test('dependsOnBlocLint detects bloc_lint dev dependency', () async {
createPackage(tempDir, 'pkg', 'pkg');
final dir = Directory(p.join(tempDir.path, 'pkg'));
File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync('''
name: pkg
environment:
sdk: ^3.0.0
dev_dependencies:
bloc_lint: ^1.0.0
''');
expect(RepositoryAnalyzer.dependsOnBlocLint(root: dir), isTrue);
});
test('dependsOnBlocLint returns false without dev dependency', () async {
createPackage(tempDir, 'pkg', 'pkg');
final dir = Directory(p.join(tempDir.path, 'pkg'));
expect(RepositoryAnalyzer.dependsOnBlocLint(root: dir), isFalse);
});
test('dependsOnBlocLint returns false when pubspec is missing', () async {
final dir = Directory(p.join(tempDir.path, 'no_pubspec'))
..createSync(recursive: true);
expect(RepositoryAnalyzer.dependsOnBlocLint(root: dir), isFalse);
});
test('subpackages discovers nested packages', () async {
createPackage(tempDir, 'parent', 'parent');
createPackage(tempDir, 'parent/example', 'parent_example');
// A directory without a pubspec should be ignored.
Directory(
p.join(tempDir.path, 'parent', 'doc'),
).createSync(recursive: true);
final parent = PackageDescription(
name: 'parent',
rootPath: p.join(tempDir.path, 'parent'),
);
final subs = RepositoryAnalyzer.subpackages(package: parent).toList();
expect(subs, hasLength(1));
expect(subs.single.name, equals('parent_example'));
});
test('subpackages prunes nested git repos', () async {
// A vendored package committed with its own .git directory (e.g.
// a forked third-party package) must not appear as a subpackage —
// the matrix would otherwise run `pub get` against unrelated code.
createPackage(tempDir, 'parent', 'parent');
createPackage(tempDir, 'parent/vendored', 'vendored');
Directory(
p.join(tempDir.path, 'parent/vendored/.git'),
).createSync(recursive: true);
final parent = PackageDescription(
name: 'parent',
rootPath: p.join(tempDir.path, 'parent'),
);
final subs = RepositoryAnalyzer.subpackages(package: parent).toList();
expect(subs, isEmpty);
});
test('subpackages skips nested directories without a name field', () async {
// Confirms the `name == null → null` filter on line 247 of the
// repository analyzer; a pubspec without a `name` field would
// otherwise yield a PackageDescription with a `null` name.
createPackage(tempDir, 'parent', 'parent');
final nested = Directory(p.join(tempDir.path, 'parent', 'oddball'))
..createSync(recursive: true);
File(p.join(nested.path, 'pubspec.yaml')).writeAsStringSync(
// Valid YAML map but no `name` key.
'environment:\n sdk: ^3.0.0\n',
);
final parent = PackageDescription(
name: 'parent',
rootPath: p.join(tempDir.path, 'parent'),
);
expect(
RepositoryAnalyzer.subpackages(package: parent).toList(),
isEmpty,
);
});
test(
'dependsOnFlutter does not stack-overflow on self-referencing workspace',
() async {
// A pubspec that declares both `workspace:` and `resolution:
// workspace` would cause findWorkspaceRoot to return itself
// and recurse forever. Guarded by an equality check.
File(p.join(tempDir.path, 'pubspec.yaml')).writeAsStringSync('''
name: weird
resolution: workspace
environment:
sdk: ^3.0.0
workspace:
- packages/foo
''');
// Should return false (no Flutter anywhere) without recursing.
expect(
RepositoryAnalyzer.dependsOnFlutter(root: tempDir),
isFalse,
);
},
);
test(
'dependsOnFlutter false for workspace member when root has no Flutter',
() async {
// Workspace root has no Flutter dep.
File(p.join(tempDir.path, 'pubspec.yaml')).writeAsStringSync('''
name: _
environment:
sdk: ^3.0.0
workspace:
- packages/foo
''');
createPackage(
tempDir,
'packages/foo',
'foo',
useWorkspace: true,
);
final fooDir = Directory(p.join(tempDir.path, 'packages/foo'));
expect(RepositoryAnalyzer.dependsOnFlutter(root: fooDir), isFalse);
},
);
});
group('posixRelative', () {
test('returns forward-slash separators regardless of input', () {
// On POSIX hosts the input is already forward-slash; on Windows
// p.relative would emit backslashes. The helper must always
// return POSIX so the path is safe to embed in YAML/shell that
// runs on a Linux runner.
final result = posixRelative('/repo/packages/foo', from: '/repo');
expect(result, equals('packages/foo'));
expect(result, isNot(contains(r'\')));
});
test('explicitly converts backslashes to forward slashes', () {
// Simulate what p.relative would emit on Windows.
const windowsLike = r'packages\foo\bar';
expect(windowsLike.replaceAll(r'\', '/'), equals('packages/foo/bar'));
});
});
}
+118
View File
@@ -0,0 +1,118 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
/// A test-scoped temp directory created in `setUp` and removed in
/// `tearDown`. Call [setUpTempDir] from inside a `group` (or at the
/// top of a `main()`) to wire up the lifecycle.
late Directory tempDir;
/// Registers `setUp` and `tearDown` to create and remove [tempDir]
/// for each test. [prefix] is used as the temp dir name prefix to
/// help identify cleanup leftovers if anything goes wrong.
void setUpTempDir(String prefix) {
setUp(() {
tempDir = Directory.systemTemp.createTempSync(prefix);
});
tearDown(() {
tempDir.deleteSync(recursive: true);
});
}
/// Creates a Dart package at [relativePath] under [root] with a pubspec.yaml
/// containing the given [name] and optional [dependencies] (path deps).
///
/// [sdkLine] controls the `environment.sdk` constraint; [flutterLine] adds
/// a Flutter dep if non-null; [useWorkspace] adds `resolution: workspace`.
void createPackage(
Directory root,
String relativePath,
String name, {
Map<String, String>? dependencies,
String sdkLine = ' sdk: ^3.0.0',
String? flutterLine,
bool useWorkspace = false,
bool addTestDir = false,
}) {
final dir = Directory(p.join(root.path, relativePath))
..createSync(recursive: true);
final buffer = StringBuffer()..writeln('name: $name');
if (useWorkspace) buffer.writeln('resolution: workspace');
buffer
..writeln('environment:')
..writeln(sdkLine);
if (flutterLine != null && flutterLine != 'any') {
// Quote the value so YAML doesn't choke on `>`/`<` etc.
buffer.writeln(' flutter: "$flutterLine"');
}
if (flutterLine != null || dependencies != null) {
final hasFlutterSdk = flutterLine == 'any';
if (hasFlutterSdk || (dependencies != null && dependencies.isNotEmpty)) {
buffer.writeln('dependencies:');
if (hasFlutterSdk) {
buffer
..writeln(' flutter:')
..writeln(' sdk: flutter');
}
if (dependencies != null) {
for (final entry in dependencies.entries) {
buffer
..writeln(' ${entry.key}:')
..writeln(' path: ${entry.value}');
}
}
}
}
File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync(buffer.toString());
if (addTestDir) {
final testDir = Directory(p.join(dir.path, 'test'))
..createSync(recursive: true);
File(
p.join(testDir.path, 'example_test.dart'),
).writeAsStringSync('void main() {}');
}
}
/// Creates a Dart workspace root pubspec at [root] listing [members].
void createWorkspaceRoot(
Directory root, {
required List<String> members,
String name = '_',
}) {
final buffer = StringBuffer()
..writeln('name: $name')
..writeln('environment:')
..writeln(' sdk: ^3.0.0')
..writeln('workspace:');
for (final member in members) {
buffer.writeln(' - $member');
}
File(p.join(root.path, 'pubspec.yaml')).writeAsStringSync(buffer.toString());
}
const _gitIdentity = ['-c', 'user.email=t@t', '-c', 'user.name=t'];
/// Stages all changes in [dir] and creates a commit with [message].
void commitAll(Directory dir, String message) {
Process.runSync(
'git',
[..._gitIdentity, 'add', '-A'],
workingDirectory: dir.path,
);
Process.runSync(
'git',
[..._gitIdentity, 'commit', '-qm', message],
workingDirectory: dir.path,
);
}
/// Initializes [dir] as a git repo with a single initial commit.
void initGitRepo(Directory dir) {
Process.runSync('git', ['init', '-q'], workingDirectory: dir.path);
commitAll(dir, 'init');
}
@@ -0,0 +1,122 @@
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_ci/src/action_versions.dart';
import 'package:shorebird_ci/src/commands/commands.dart';
import 'package:test/test.dart';
import 'test_utils.dart';
Future<int?> runUpdate(
Directory workflowDir, {
LatestMajorResolver? resolveLatestMajor,
}) async {
final runner = CommandRunner<int>('test', 'test')
..addCommand(
UpdateActionsCommand(resolveLatestMajor: resolveLatestMajor),
);
return runner.run([
'update_actions',
'--workflow-dir',
workflowDir.path,
]);
}
void main() {
setUpTempDir('shorebird_ci_update_actions_');
test('returns 1 when workflow dir does not exist', () async {
final missing = Directory(p.join(tempDir.path, 'nope'));
expect(await runUpdate(missing), 1);
});
test('returns 0 when no workflow files exist', () async {
expect(await runUpdate(tempDir), 0);
});
test('leaves files without `uses:` references unchanged', () async {
final file = File(p.join(tempDir.path, 'simple.yaml'));
const content = '''
name: simple
on: [push]
jobs:
hello:
runs-on: ubuntu-latest
steps:
- run: echo hi
''';
file.writeAsStringSync(content);
expect(await runUpdate(tempDir), 0);
expect(file.readAsStringSync(), equals(content));
});
test('leaves SHA-pinned actions unchanged', () async {
final file = File(p.join(tempDir.path, 'sha_pinned.yaml'));
const content = '''
steps:
- uses: actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
''';
file.writeAsStringSync(content);
expect(await runUpdate(tempDir), 0);
expect(file.readAsStringSync(), equals(content));
});
test(
'rewrites action versions when the resolver returns a new major',
() async {
final file = File(p.join(tempDir.path, 'has_uses.yaml'));
const before = '''
steps:
- uses: actions/checkout@v3
''';
file.writeAsStringSync(before);
Future<String?> resolver(String repo) async => 'v9';
expect(
await runUpdate(tempDir, resolveLatestMajor: resolver),
0,
);
expect(
file.readAsStringSync(),
equals('''
steps:
- uses: actions/checkout@v9
'''),
);
},
);
test('leaves files alone when the resolver returns the same major', () async {
final file = File(p.join(tempDir.path, 'already_latest.yaml'));
const content = '''
steps:
- uses: actions/checkout@v4
''';
file.writeAsStringSync(content);
Future<String?> resolver(String repo) async => 'v4';
expect(await runUpdate(tempDir, resolveLatestMajor: resolver), 0);
expect(file.readAsStringSync(), equals(content));
});
test('skips actions when the resolver returns null', () async {
// Covers the `latest == null` early-continue branch.
final file = File(p.join(tempDir.path, 'unresolvable.yaml'));
const content = '''
steps:
- uses: actions/checkout@v3
''';
file.writeAsStringSync(content);
Future<String?> resolver(String repo) async => null;
expect(await runUpdate(tempDir, resolveLatestMajor: resolver), 0);
expect(file.readAsStringSync(), equals(content));
});
test('exposes a non-empty description', () {
expect(UpdateActionsCommand().description, isNotEmpty);
});
}
@@ -0,0 +1,155 @@
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_ci/src/commands/commands.dart';
import 'package:test/test.dart';
import 'test_utils.dart';
Future<int?> runVerify(
Directory repoRoot, {
List<String> extra = const [],
}) async {
final runner = CommandRunner<int>('test', 'test')
..addCommand(VerifyCommand());
return runner.run([
'verify',
'--repo-root',
repoRoot.path,
...extra,
]);
}
void _writeWorkflow(Directory repoRoot, String name, String content) {
final workflows = Directory(
p.join(repoRoot.path, '.github', 'workflows'),
)..createSync(recursive: true);
File(p.join(workflows.path, name)).writeAsStringSync(content);
}
void main() {
setUpTempDir('shorebird_ci_verify_');
test('returns 1 when no .github/workflows directory exists', () async {
createPackage(tempDir, 'packages/foo', 'foo');
initGitRepo(tempDir);
expect(await runVerify(tempDir), 1);
});
test('dynamic workflow → passes even with packages in repo', () async {
createPackage(tempDir, 'packages/foo', 'foo');
createPackage(tempDir, 'packages/bar', 'bar');
_writeWorkflow(tempDir, 'ci.yaml', '''
# shorebird_ci-managed: dynamic
name: CI
on: [push]
jobs:
setup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: shorebird_ci affected_packages --sdk dart
''');
initGitRepo(tempDir);
expect(await runVerify(tempDir), 0);
});
test('mention without marker does not count as dynamic coverage', () async {
// Regression: an earlier version substring-searched for the call,
// so any YAML comment, run-string, or commented-out step that
// happened to mention `shorebird_ci affected_packages` would be
// treated as dynamic coverage. The marker comment makes intent
// explicit.
createPackage(tempDir, 'packages/foo', 'foo');
_writeWorkflow(tempDir, 'ci.yaml', '''
name: CI
# Note: shorebird_ci affected_packages used to live here.
on: [push]
jobs:
hello:
runs-on: ubuntu-latest
steps:
- run: echo "see shorebird_ci affected_packages docs"
''');
initGitRepo(tempDir);
// No marker → not dynamic → falls back to static dorny check →
// foo isn't in any filter → fail.
expect(await runVerify(tempDir), 1);
});
test('static workflow with all packages covered → passes', () async {
createPackage(tempDir, 'packages/foo', 'foo');
createPackage(tempDir, 'packages/bar', 'bar');
_writeWorkflow(tempDir, 'ci.yaml', '''
name: CI
on: [push]
jobs:
changes:
runs-on: ubuntu-latest
steps:
- uses: dorny/paths-filter@v3
with:
filters: |
foo:
- packages/foo/**
bar:
- packages/bar/**
''');
initGitRepo(tempDir);
expect(await runVerify(tempDir), 0);
});
test('static workflow missing a package → returns 1', () async {
createPackage(tempDir, 'packages/foo', 'foo');
createPackage(tempDir, 'packages/missing_one', 'missing_one');
_writeWorkflow(tempDir, 'ci.yaml', '''
name: CI
on: [push]
jobs:
changes:
runs-on: ubuntu-latest
steps:
- uses: dorny/paths-filter@v3
with:
filters: |
foo:
- packages/foo/**
''');
initGitRepo(tempDir);
expect(await runVerify(tempDir), 1);
});
test('exposes a non-empty description', () {
expect(VerifyCommand().description, isNotEmpty);
});
test('--ignore excludes named packages', () async {
createPackage(tempDir, 'packages/foo', 'foo');
createPackage(tempDir, 'packages/e2e', 'e2e');
_writeWorkflow(tempDir, 'ci.yaml', '''
name: CI
on: [push]
jobs:
changes:
runs-on: ubuntu-latest
steps:
- uses: dorny/paths-filter@v3
with:
filters: |
foo:
- packages/foo/**
''');
initGitRepo(tempDir);
// Without --ignore, e2e is flagged as missing.
expect(await runVerify(tempDir), 1);
// With --ignore, e2e is skipped.
expect(await runVerify(tempDir, extra: ['--ignore', 'e2e']), 0);
});
}
@@ -0,0 +1,169 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:shorebird_ci/src/workspace.dart';
import 'package:test/test.dart';
import 'test_utils.dart';
void main() {
setUpTempDir('shorebird_ci_workspace_');
void writePubspec(String relativePath, String content) {
final dir = Directory(p.join(tempDir.path, relativePath))
..createSync(recursive: true);
File(p.join(dir.path, 'pubspec.yaml')).writeAsStringSync(content);
}
group('isDartWorkspace', () {
test('true when workspace: list is non-empty', () {
writePubspec('.', '''
name: _
environment:
sdk: ^3.0.0
workspace:
- packages/foo
''');
expect(isDartWorkspace(tempDir.path), isTrue);
});
test('false when no pubspec.yaml exists', () {
expect(isDartWorkspace(tempDir.path), isFalse);
});
test('false when workspace key is missing', () {
writePubspec('.', '''
name: foo
environment:
sdk: ^3.0.0
''');
expect(isDartWorkspace(tempDir.path), isFalse);
});
test('false when workspace: list is empty', () {
writePubspec('.', '''
name: _
environment:
sdk: ^3.0.0
workspace: []
''');
expect(isDartWorkspace(tempDir.path), isFalse);
});
});
group('isWorkspaceStubRoot', () {
test('true for nameless workspace', () {
writePubspec('.', '''
environment:
sdk: ^3.0.0
workspace:
- packages/foo
''');
expect(isWorkspaceStubRoot(tempDir.path), isTrue);
});
test('true for underscore-prefixed name', () {
writePubspec('.', '''
name: _
environment:
sdk: ^3.0.0
workspace:
- packages/foo
''');
expect(isWorkspaceStubRoot(tempDir.path), isTrue);
});
test('false for normal-named workspace', () {
writePubspec('.', '''
name: my_workspace
environment:
sdk: ^3.0.0
workspace:
- packages/foo
''');
expect(isWorkspaceStubRoot(tempDir.path), isFalse);
});
test('false for non-workspace package', () {
writePubspec('.', '''
name: foo
environment:
sdk: ^3.0.0
''');
expect(isWorkspaceStubRoot(tempDir.path), isFalse);
});
});
group('usesWorkspaceResolution', () {
test('true when resolution: workspace is set', () {
writePubspec('.', '''
name: foo
resolution: workspace
environment:
sdk: ^3.0.0
''');
expect(usesWorkspaceResolution(tempDir.path), isTrue);
});
test('false when resolution key is missing', () {
writePubspec('.', '''
name: foo
environment:
sdk: ^3.0.0
''');
expect(usesWorkspaceResolution(tempDir.path), isFalse);
});
test('false when no pubspec exists', () {
expect(usesWorkspaceResolution(tempDir.path), isFalse);
});
});
group('findWorkspaceRoot', () {
test('walks up to find a workspace root', () {
writePubspec('.', '''
name: _
environment:
sdk: ^3.0.0
workspace:
- packages/foo
''');
writePubspec('packages/foo', '''
name: foo
resolution: workspace
environment:
sdk: ^3.0.0
''');
final memberPath = p.join(tempDir.path, 'packages', 'foo');
final root = findWorkspaceRoot(memberPath);
expect(root, isNotNull);
expect(p.equals(root!.path, tempDir.path), isTrue);
});
test('returns the directory itself if it is the workspace root', () {
writePubspec('.', '''
name: _
environment:
sdk: ^3.0.0
workspace:
- packages/foo
''');
final root = findWorkspaceRoot(tempDir.path);
expect(root, isNotNull);
expect(p.equals(root!.path, tempDir.path), isTrue);
});
test('returns null when no workspace root exists above', () {
writePubspec('packages/foo', '''
name: foo
environment:
sdk: ^3.0.0
''');
final memberPath = p.join(tempDir.path, 'packages', 'foo');
// No workspace root anywhere up the tree.
expect(findWorkspaceRoot(memberPath), isNull);
});
});
}
+1
View File
@@ -11,6 +11,7 @@ workspace:
- packages/redis_client
- packages/scoped_deps
- packages/shorebird_build_trace
- packages/shorebird_ci
- packages/shorebird_cli
- packages/shorebird_code_push_client
- packages/shorebird_code_push_protocol