diff --git a/cspell.config.yaml b/cspell.config.yaml index a3e967b9..57b3b822 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -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 diff --git a/packages/shorebird_ci/CONTRIBUTING.md b/packages/shorebird_ci/CONTRIBUTING.md new file mode 100644 index 00000000..6e82153d --- /dev/null +++ b/packages/shorebird_ci/CONTRIBUTING.md @@ -0,0 +1,9 @@ +# Contributing + +We are happy to accept contributions! + +## Developing + +### Running Tests + +All you need to do is run `dart test`. diff --git a/packages/shorebird_ci/LICENSE b/packages/shorebird_ci/LICENSE new file mode 100644 index 00000000..9cf10627 --- /dev/null +++ b/packages/shorebird_ci/LICENSE @@ -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. diff --git a/packages/shorebird_ci/README.md b/packages/shorebird_ci/README.md new file mode 100644 index 00000000..2a9ea65a --- /dev/null +++ b/packages/shorebird_ci/README.md @@ -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 15–30 +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. diff --git a/packages/shorebird_ci/analysis_options.yaml b/packages/shorebird_ci/analysis_options.yaml new file mode 100644 index 00000000..f04c6cf0 --- /dev/null +++ b/packages/shorebird_ci/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/shorebird_ci/bin/shorebird_ci.dart b/packages/shorebird_ci/bin/shorebird_ci.dart new file mode 100644 index 00000000..1c7b584e --- /dev/null +++ b/packages/shorebird_ci/bin/shorebird_ci.dart @@ -0,0 +1,7 @@ +import 'dart:io'; + +import 'package:shorebird_ci/src/shorebird_ci_command_runner.dart'; + +Future main(List args) async { + exit(await ShorebirdCiCommandRunner().run(args) ?? 0); +} diff --git a/packages/shorebird_ci/lib/shorebird_ci.dart b/packages/shorebird_ci/lib/shorebird_ci.dart new file mode 100644 index 00000000..cdf7a794 --- /dev/null +++ b/packages/shorebird_ci/lib/shorebird_ci.dart @@ -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'; diff --git a/packages/shorebird_ci/lib/src/action_versions.dart b/packages/shorebird_ci/lib/src/action_versions.dart new file mode 100644 index 00000000..600792f4 --- /dev/null +++ b/packages/shorebird_ci/lib/src/action_versions.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 `/` on GitHub. Returns `null` if no version can be +/// determined (network failure, missing release, malformed tag, etc.). +typedef LatestMajorResolver = Future 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///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 updateActionVersions( + String workflowContent, { + LatestMajorResolver? resolveLatestMajor, +}) async { + final actions = _extractActions(workflowContent); + if (actions.isEmpty) return workflowContent; + + final resolver = resolveLatestMajor ?? _defaultResolver; + final latestByRepo = {}; + 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 _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 = {}; + 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 _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; + 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 diff --git a/packages/shorebird_ci/lib/src/affected_packages.dart b/packages/shorebird_ci/lib/src/affected_packages.dart new file mode 100644 index 00000000..3f7fd5e4 --- /dev/null +++ b/packages/shorebird_ci/lib/src/affected_packages.dart @@ -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> 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(); +} diff --git a/packages/shorebird_ci/lib/src/codecov.dart b/packages/shorebird_ci/lib/src/codecov.dart new file mode 100644 index 00000000..2e1383d1 --- /dev/null +++ b/packages/shorebird_ci/lib/src/codecov.dart @@ -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'), +}; diff --git a/packages/shorebird_ci/lib/src/commands/affected_packages_command.dart b/packages/shorebird_ci/lib/src/commands/affected_packages_command.dart new file mode 100644 index 00000000..8489df45 --- /dev/null +++ b/packages/shorebird_ci/lib/src/commands/affected_packages_command.dart @@ -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 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 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; + } +} diff --git a/packages/shorebird_ci/lib/src/commands/commands.dart b/packages/shorebird_ci/lib/src/commands/commands.dart new file mode 100644 index 00000000..a24b4380 --- /dev/null +++ b/packages/shorebird_ci/lib/src/commands/commands.dart @@ -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'; diff --git a/packages/shorebird_ci/lib/src/commands/flutter_version_command.dart b/packages/shorebird_ci/lib/src/commands/flutter_version_command.dart new file mode 100644 index 00000000..b41af7e2 --- /dev/null +++ b/packages/shorebird_ci/lib/src/commands/flutter_version_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 { + /// 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 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; + } +} diff --git a/packages/shorebird_ci/lib/src/commands/generate_command.dart b/packages/shorebird_ci/lib/src/commands/generate_command.dart new file mode 100644 index 00000000..75c15120 --- /dev/null +++ b/packages/shorebird_ci/lib/src/commands/generate_command.dart @@ -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 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 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 _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 = { + 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 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(); + } +} diff --git a/packages/shorebird_ci/lib/src/commands/repo_root_option.dart b/packages/shorebird_ci/lib/src/commands/repo_root_option.dart new file mode 100644 index 00000000..2c1ec506 --- /dev/null +++ b/packages/shorebird_ci/lib/src/commands/repo_root_option.dart @@ -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 { + /// 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? ?? '.'; +} diff --git a/packages/shorebird_ci/lib/src/commands/update_actions_command.dart b/packages/shorebird_ci/lib/src/commands/update_actions_command.dart new file mode 100644 index 00000000..0a875836 --- /dev/null +++ b/packages/shorebird_ci/lib/src/commands/update_actions_command.dart @@ -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 { + /// 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 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() + .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; + } +} diff --git a/packages/shorebird_ci/lib/src/commands/verify_command.dart b/packages/shorebird_ci/lib/src/commands/verify_command.dart new file mode 100644 index 00000000..e257c4af --- /dev/null +++ b/packages/shorebird_ci/lib/src/commands/verify_command.dart @@ -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 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 run() async { + final ignoreStr = argResults!['ignore'] as String?; + final ignoreSet = ignoreStr != null + ? ignoreStr.split(',').map((s) => s.trim()).toSet() + : {}; + + 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 = >{}; + final workflowFiles = + workflowDir + .listSync() + .whereType() + .where((f) => f.path.endsWith('.yaml')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + + final dynamicWorkflows = []; + + 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 = []; + + 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); + } +} diff --git a/packages/shorebird_ci/lib/src/cspell.dart b/packages/shorebird_ci/lib/src/cspell.dart new file mode 100644 index 00000000..f52eed51 --- /dev/null +++ b/packages/shorebird_ci/lib/src/cspell.dart @@ -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'), +}; diff --git a/packages/shorebird_ci/lib/src/dependency_resolver.dart b/packages/shorebird_ci/lib/src/dependency_resolver.dart new file mode 100644 index 00000000..cccc924e --- /dev/null +++ b/packages/shorebird_ci/lib/src/dependency_resolver.dart @@ -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 resolve(String packageDir) { + final visited = {}; + final queue = [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 _getPathDependencies(String packageDir) { + final pubspec = readPubspec(p.join(repoRoot, packageDir)); + final dependencies = pubspec?['dependencies'] as YamlMap?; + if (dependencies == null) return const []; + + final deps = []; + 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; + } +} diff --git a/packages/shorebird_ci/lib/src/dorny_filter.dart b/packages/shorebird_ci/lib/src/dorny_filter.dart new file mode 100644 index 00000000..a61abf9a --- /dev/null +++ b/packages/shorebird_ci/lib/src/dorny_filter.dart @@ -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 extractDornyFilterNames(String workflowContent) { + final names = {}; + + 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; +} diff --git a/packages/shorebird_ci/lib/src/flutter_version_resolver.dart b/packages/shorebird_ci/lib/src/flutter_version_resolver.dart new file mode 100644 index 00000000..32a766bf --- /dev/null +++ b/packages/shorebird_ci/lib/src/flutter_version_resolver.dart @@ -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'; diff --git a/packages/shorebird_ci/lib/src/git.dart b/packages/shorebird_ci/lib/src/git.dart new file mode 100644 index 00000000..2657d2a4 --- /dev/null +++ b/packages/shorebird_ci/lib/src/git.dart @@ -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 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 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 +/// [ ()] +/// ``` +/// where `` is one of space (initialized), `-` (uninitialized), +/// `+` (commit doesn't match index), `U` (merge conflicts). +/// `` may contain spaces. `()` is optional and +/// only present when the submodule is initialized. +List 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() + .toList(); +} diff --git a/packages/shorebird_ci/lib/src/package_description.dart b/packages/shorebird_ci/lib/src/package_description.dart new file mode 100644 index 00000000..601d8931 --- /dev/null +++ b/packages/shorebird_ci/lib/src/package_description.dart @@ -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); +} diff --git a/packages/shorebird_ci/lib/src/pubspec.dart b/packages/shorebird_ci/lib/src/pubspec.dart new file mode 100644 index 00000000..f8788ae9 --- /dev/null +++ b/packages/shorebird_ci/lib/src/pubspec.dart @@ -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; + } +} diff --git a/packages/shorebird_ci/lib/src/repository_analyzer.dart b/packages/shorebird_ci/lib/src/repository_analyzer.dart new file mode 100644 index 00000000..b4f70004 --- /dev/null +++ b/packages/shorebird_ci/lib/src/repository_analyzer.dart @@ -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 = []; + 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() + .toList(); + + _checkForDuplicateNames(packageDescriptions); + + return RepositoryDescription( + packages: packageDescriptions, + root: repositoryRoot, + hasCodecov: hasCodecov, + cspellConfig: cSpellConfigFile, + ); + } + + static void _checkForDuplicateNames( + List packages, + ) { + final byName = >{}; + 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 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 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 _filterPackages({ + required List pubspecFiles, + required Directory repositoryRoot, + }) { + final packages = []; + 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 subpackages({ + required PackageDescription package, + }) { + final found = []; + 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 _recursivePackageDependencies({ + required RepositoryDescription repository, + required PackageDescription package, + }) { + final ret = {}; + 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 _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() + .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'\', '/'); +} diff --git a/packages/shorebird_ci/lib/src/repository_description.dart b/packages/shorebird_ci/lib/src/repository_description.dart new file mode 100644 index 00000000..f8858cf5 --- /dev/null +++ b/packages/shorebird_ci/lib/src/repository_description.dart @@ -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 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; +} diff --git a/packages/shorebird_ci/lib/src/shorebird_ci_command_runner.dart b/packages/shorebird_ci/lib/src/shorebird_ci_command_runner.dart new file mode 100644 index 00000000..30ce0f56 --- /dev/null +++ b/packages/shorebird_ci/lib/src/shorebird_ci_command_runner.dart @@ -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 { + /// Creates a [ShorebirdCiCommandRunner]. + ShorebirdCiCommandRunner() + : super('shorebird_ci', 'CI tooling for Dart/Flutter monorepos') { + addCommand(AffectedPackagesCommand()); + addCommand(FlutterVersionCommand()); + addCommand(GenerateCommand()); + addCommand(UpdateActionsCommand()); + addCommand(VerifyCommand()); + } +} diff --git a/packages/shorebird_ci/lib/src/workspace.dart b/packages/shorebird_ci/lib/src/workspace.dart new file mode 100644 index 00000000..3b1335a0 --- /dev/null +++ b/packages/shorebird_ci/lib/src/workspace.dart @@ -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; +} diff --git a/packages/shorebird_ci/pubspec.yaml b/packages/shorebird_ci/pubspec.yaml new file mode 100644 index 00000000..44b4d9bc --- /dev/null +++ b/packages/shorebird_ci/pubspec.yaml @@ -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 diff --git a/packages/shorebird_ci/test/action_versions_test.dart b/packages/shorebird_ci/test/action_versions_test.dart new file mode 100644 index 00000000..face7cbb --- /dev/null +++ b/packages/shorebird_ci/test/action_versions_test.dart @@ -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')); + }); + }); +} diff --git a/packages/shorebird_ci/test/affected_packages_command_test.dart b/packages/shorebird_ci/test/affected_packages_command_test.dart new file mode 100644 index 00000000..151d7713 --- /dev/null +++ b/packages/shorebird_ci/test/affected_packages_command_test.dart @@ -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().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()), + ); + }); + + // 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('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('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); + }); +} diff --git a/packages/shorebird_ci/test/dependency_resolver_test.dart b/packages/shorebird_ci/test/dependency_resolver_test.dart new file mode 100644 index 00000000..26586715 --- /dev/null +++ b/packages/shorebird_ci/test/dependency_resolver_test.dart @@ -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'])); + }); + }); +} diff --git a/packages/shorebird_ci/test/dorny_filter_test.dart b/packages/shorebird_ci/test/dorny_filter_test.dart new file mode 100644 index 00000000..7abf0c2b --- /dev/null +++ b/packages/shorebird_ci/test/dorny_filter_test.dart @@ -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'}), + ); + }); + }); +} diff --git a/packages/shorebird_ci/test/flutter_version_command_test.dart b/packages/shorebird_ci/test/flutter_version_command_test.dart new file mode 100644 index 00000000..0e4a74e1 --- /dev/null +++ b/packages/shorebird_ci/test/flutter_version_command_test.dart @@ -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 runFlutterVersion(Directory pkgDir) async { + final runner = CommandRunner('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); + }); +} diff --git a/packages/shorebird_ci/test/flutter_version_resolver_test.dart b/packages/shorebird_ci/test/flutter_version_resolver_test.dart new file mode 100644 index 00000000..aa08aecf --- /dev/null +++ b/packages/shorebird_ci/test/flutter_version_resolver_test.dart @@ -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'), + ); + }); + }); +} diff --git a/packages/shorebird_ci/test/generate_command_test.dart b/packages/shorebird_ci/test/generate_command_test.dart new file mode 100644 index 00000000..606b7f16 --- /dev/null +++ b/packages/shorebird_ci/test/generate_command_test.dart @@ -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 runGenerate( + Directory repoRoot, { + List extra = const [], +}) async { + final runner = CommandRunner('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(); +} diff --git a/packages/shorebird_ci/test/git_test.dart b/packages/shorebird_ci/test/git_test.dart new file mode 100644 index 00000000..a7b4fc60 --- /dev/null +++ b/packages/shorebird_ci/test/git_test.dart @@ -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()), + ); + }); + + 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'])); + }); + }); +} diff --git a/packages/shorebird_ci/test/package_description_test.dart b/packages/shorebird_ci/test/package_description_test.dart new file mode 100644 index 00000000..9bd8af5e --- /dev/null +++ b/packages/shorebird_ci/test/package_description_test.dart @@ -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); + }); + }); +} diff --git a/packages/shorebird_ci/test/pubspec_test.dart b/packages/shorebird_ci/test/pubspec_test.dart new file mode 100644 index 00000000..2713e586 --- /dev/null +++ b/packages/shorebird_ci/test/pubspec_test.dart @@ -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')); + }); +} diff --git a/packages/shorebird_ci/test/repository_analyzer_test.dart b/packages/shorebird_ci/test/repository_analyzer_test.dart new file mode 100644 index 00000000..6844210b --- /dev/null +++ b/packages/shorebird_ci/test/repository_analyzer_test.dart @@ -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 paths; + + @override + List 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()), + ); + }); + + 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().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().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')); + }); + }); +} diff --git a/packages/shorebird_ci/test/test_utils.dart b/packages/shorebird_ci/test/test_utils.dart new file mode 100644 index 00000000..d03c0896 --- /dev/null +++ b/packages/shorebird_ci/test/test_utils.dart @@ -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? 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 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'); +} diff --git a/packages/shorebird_ci/test/update_actions_command_test.dart b/packages/shorebird_ci/test/update_actions_command_test.dart new file mode 100644 index 00000000..50759fda --- /dev/null +++ b/packages/shorebird_ci/test/update_actions_command_test.dart @@ -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 runUpdate( + Directory workflowDir, { + LatestMajorResolver? resolveLatestMajor, +}) async { + final runner = CommandRunner('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 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 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 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); + }); +} diff --git a/packages/shorebird_ci/test/verify_command_test.dart b/packages/shorebird_ci/test/verify_command_test.dart new file mode 100644 index 00000000..416e5a69 --- /dev/null +++ b/packages/shorebird_ci/test/verify_command_test.dart @@ -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 runVerify( + Directory repoRoot, { + List extra = const [], +}) async { + final runner = CommandRunner('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); + }); +} diff --git a/packages/shorebird_ci/test/workspace_test.dart b/packages/shorebird_ci/test/workspace_test.dart new file mode 100644 index 00000000..2fc753cd --- /dev/null +++ b/packages/shorebird_ci/test/workspace_test.dart @@ -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); + }); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index c929d7b4..ceacb130 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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