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