diff --git a/README.md b/README.md index c0755b05..34697ca0 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ This repository is a monorepo containing the following packages: | [shorebird_cli](packages/shorebird_cli/README.md) | Command-line which allows developers to interact with various Shorebird services | | [shorebird_code_push_client](packages/shorebird_code_push_client/README.md) | Dart library which allows Dart applications to interact with the Shorebird CodePush API | | [shorebird_code_push_protocol](packages/shorebird_code_push_protocol/README.md) | Dart library which contains common interfaces used by Shorebird CodePush | -| [artifact_proxy](packages/artifact_proxy/README.md) | Dart server which supports intercepting and proxying Flutter artifact requests. | +| [artifact_proxy](packages/artifact_proxy/README.md) | Dart server which supports intercepting and proxying Flutter artifact requests | | [discord_gcp_alerts](packages/discord_gcp_alerts/README.md) | Dart server which forwards GCP alerts to Discord | +| [flutter_version_resolver](packages/flutter_version_resolver/README.md) | Command-line utility that determines which Flutter version should be used for a project | | [jwt](packages/jwt/README.md) | Dart library for verifying JSON Web Tokens | | [redis_client](packages/redis_client/README.md) | Dart library for interacting with Redis | | [scoped_deps](packages/scoped_deps/README.md) | A simple dependency injection library built on Zones | diff --git a/packages/flutter_version_resolver/.gitignore b/packages/flutter_version_resolver/.gitignore new file mode 100644 index 00000000..3a857904 --- /dev/null +++ b/packages/flutter_version_resolver/.gitignore @@ -0,0 +1,3 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ diff --git a/packages/flutter_version_resolver/CONTRIBUTING.md b/packages/flutter_version_resolver/CONTRIBUTING.md new file mode 100644 index 00000000..b50c4233 --- /dev/null +++ b/packages/flutter_version_resolver/CONTRIBUTING.md @@ -0,0 +1,11 @@ +# Contributing + +We are happy to accept contributions! + +## Developing + +This library has 100% coverage and all PRs are expected to be tested. + +### Running Tests + +All you need to do is run `dart test`. \ No newline at end of file diff --git a/packages/flutter_version_resolver/LICENSE b/packages/flutter_version_resolver/LICENSE new file mode 100644 index 00000000..6802bc4b --- /dev/null +++ b/packages/flutter_version_resolver/LICENSE @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/flutter_version_resolver/README.md b/packages/flutter_version_resolver/README.md new file mode 100644 index 00000000..b7837bd5 --- /dev/null +++ b/packages/flutter_version_resolver/README.md @@ -0,0 +1,4 @@ +# Flutter Version Resolver + +Analyzes Flutter packages to determine the Flutter version that should be used +to build and test. diff --git a/packages/flutter_version_resolver/analysis_options.yaml b/packages/flutter_version_resolver/analysis_options.yaml new file mode 100644 index 00000000..fac60e24 --- /dev/null +++ b/packages/flutter_version_resolver/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml \ No newline at end of file diff --git a/packages/flutter_version_resolver/bin/flutter_version_resolver.dart b/packages/flutter_version_resolver/bin/flutter_version_resolver.dart new file mode 100644 index 00000000..9913d13b --- /dev/null +++ b/packages/flutter_version_resolver/bin/flutter_version_resolver.dart @@ -0,0 +1,46 @@ +import 'dart:io'; + +import 'package:flutter_version_resolver/flutter_version_resolver.dart'; +import 'package:flutter_version_resolver/src/logger.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:scoped_deps/scoped_deps.dart'; + +/// Resolves the Flutter version for a package and optionally writes it to a +/// file. +/// +/// Usage: +/// ```sh +/// dart run bin/flutter_version_resolver.dart [] +/// ``` +Future main(List arguments) async { + return await runScoped(() async { + if (arguments.isEmpty || arguments.length > 2) { + logger.err( + 'Usage: dart run bin/flutter_version_resolver.dart []', + ); + return ExitCode.usage.code; + } + + final packageDirectory = Directory(arguments[0]); + if (!packageDirectory.existsSync()) { + logger.err( + 'Package directory does not exist: ${packageDirectory.path}', + ); + return ExitCode.usage.code; + } + + final flutterVersion = resolveFlutterVersion( + packagePath: packageDirectory.path, + ); + logger.info('Resolved Flutter version: $flutterVersion'); + + if (arguments.length > 1) { + final outputFile = File(arguments[1]) + ..createSync(recursive: true) + ..writeAsStringSync(flutterVersion); + logger.info('Wrote flutter version to ${outputFile.path}'); + } + + return ExitCode.success.code; + }, values: {loggerRef}); +} diff --git a/packages/flutter_version_resolver/lib/flutter_version_resolver.dart b/packages/flutter_version_resolver/lib/flutter_version_resolver.dart new file mode 100644 index 00000000..1b589da7 --- /dev/null +++ b/packages/flutter_version_resolver/lib/flutter_version_resolver.dart @@ -0,0 +1,93 @@ +import 'dart:io'; + +import 'package:flutter_version_resolver/src/logger.dart'; +import 'package:path/path.dart' as p; +import 'package:pub_semver/pub_semver.dart'; +import 'package:yaml/yaml.dart'; + +/// {@template version_constraint_exception} +/// Thrown when a version constraint is found unexpectedly. +/// {@endtemplate} +class VersionConstraintException implements Exception { + /// {@macro version_constraint_exception} + VersionConstraintException({required this.versionConstraint}); + + /// The version constraint that was found. + final String versionConstraint; +} + +/// Given a path to a Flutter package, attempts to determine the Flutter version +/// that should be used to build the package. +/// +/// This checks the following locations, in this order: +/// +/// 1. The `environment` section of the pubspec.yaml file +/// 2. TODO(bryanoltman): add fvm support +/// +/// If no version is found, this returns the `stable` version. +String resolveFlutterVersion({ + required String packagePath, +}) { + logger + ..info('Resolving Flutter version for $packagePath') + ..info('Checking pubspec.yaml environment section for flutter version'); + + try { + final flutterVersion = flutterVersionFromPubspecEnvironment( + packagePath: packagePath, + ); + if (flutterVersion != null) { + logger.info('Found flutter version in pubspec.yaml: $flutterVersion'); + return flutterVersion.toString(); + } + } on VersionConstraintException catch (e) { + logger.err( + '''Found version constraint: ${e.versionConstraint}. Version constraints are not supported in pubspec.yaml. Please specify a specific version.''', + ); + return 'stable'; + } on Exception catch (e) { + logger + ..err('Error resolving Flutter version: $e') + ..info('Falling back to "stable" branch'); + return 'stable'; + } + + logger.info('No flutter version found in pubspec.yaml, using stable'); + return 'stable'; +} + +/// Returns the Flutter version specified in the `environment` section of the +/// pubspec.yaml file, or `null` if no version is specified. +/// +/// A pubspec.yaml with the following will return `Version(3, 20, 0)`: +/// ```yaml +/// environment: +/// sdk: ^3.8.1 +/// flutter: 3.20.0 +/// ``` +Version? flutterVersionFromPubspecEnvironment({required String packagePath}) { + final pubspecFile = File(p.join(packagePath, 'pubspec.yaml')); + if (!pubspecFile.existsSync()) { + throw Exception('pubspec.yaml not found at ${pubspecFile.path}'); + } + + final pubspecYaml = loadYaml(pubspecFile.readAsStringSync()); + if (pubspecYaml is! YamlMap) { + throw Exception('Failed to parse pubspec.yaml at ${pubspecFile.path}'); + } + + final environment = pubspecYaml['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; + } + + // We were successfully able to parse the flutterVersionString, but it is a + // version constraint, not a specific version. + throw VersionConstraintException(versionConstraint: flutterVersionString); +} diff --git a/packages/flutter_version_resolver/lib/src/logger.dart b/packages/flutter_version_resolver/lib/src/logger.dart new file mode 100644 index 00000000..b711b3cc --- /dev/null +++ b/packages/flutter_version_resolver/lib/src/logger.dart @@ -0,0 +1,8 @@ +import 'package:mason_logger/mason_logger.dart'; +import 'package:scoped_deps/scoped_deps.dart'; + +/// A reference to a [Logger] instance. +final loggerRef = create(Logger.new); + +/// The [Logger] instance available in the current zone. +Logger get logger => read(loggerRef); diff --git a/packages/flutter_version_resolver/pubspec.yaml b/packages/flutter_version_resolver/pubspec.yaml new file mode 100644 index 00000000..d41a999f --- /dev/null +++ b/packages/flutter_version_resolver/pubspec.yaml @@ -0,0 +1,20 @@ +name: flutter_version_resolver +description: A Dart tool that resolves a Flutter version for a given Flutter application +version: 1.0.0 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.8.1 + +dependencies: + mason_logger: ^0.3.3 + path: ^1.9.1 + pub_semver: ^2.2.0 + scoped_deps: ^0.1.0+2 + yaml: ^3.1.3 + +dev_dependencies: + lints: ^5.0.0 + mocktail: ^1.0.4 + test: ^1.24.0 diff --git a/packages/flutter_version_resolver/test/flutter_version_resolver_test.dart b/packages/flutter_version_resolver/test/flutter_version_resolver_test.dart new file mode 100644 index 00000000..e365a3d3 --- /dev/null +++ b/packages/flutter_version_resolver/test/flutter_version_resolver_test.dart @@ -0,0 +1,173 @@ +import 'dart:io'; + +import 'package:flutter_version_resolver/flutter_version_resolver.dart'; +import 'package:flutter_version_resolver/src/logger.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; +import 'package:pub_semver/pub_semver.dart'; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:test/test.dart'; + +class _MockLogger extends Mock implements Logger {} + +void main() { + late Logger logger; + late Directory packageDirectory; + late File pubspecFile; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: {loggerRef.overrideWith(() => logger)}, + ); + } + + setUp(() { + logger = _MockLogger(); + + packageDirectory = Directory.systemTemp.createTempSync( + 'flutter_version_resolver_test', + ); + pubspecFile = File( + p.join(packageDirectory.path, 'pubspec.yaml'), + )..writeAsStringSync('name: flutter_version_resolver_test'); + }); + + group('resolveFlutterVersion', () { + group('when no flutter version is specified in the pubspec.yaml file', () { + test('returns the stable version', () { + runWithOverrides(() { + expect( + resolveFlutterVersion(packagePath: packageDirectory.path), + equals('stable'), + ); + }); + }); + }); + + group('when a flutter version is specified in the pubspec.yaml file', () { + setUp(() { + pubspecFile.writeAsStringSync(''' +environment: + sdk: ^3.8.1 + flutter: 3.20.0 +'''); + }); + + test('returns the version', () { + runWithOverrides(() { + expect( + resolveFlutterVersion(packagePath: packageDirectory.path), + equals('3.20.0'), + ); + }); + }); + }); + + group('when a version constraint is specified in the pubspec.yaml file', () { + setUp(() { + pubspecFile.writeAsStringSync(''' +environment: + sdk: ^3.8.1 + flutter: "^3.8.0" +'''); + }); + + test('prints an error message and returns the stable version', () { + runWithOverrides(() { + expect( + resolveFlutterVersion(packagePath: packageDirectory.path), + equals('stable'), + ); + }); + verify( + () => logger.err( + '''Found version constraint: ^3.8.0. Version constraints are not supported in pubspec.yaml. Please specify a specific version.''', + ), + ).called(1); + }); + }); + }); + + group('flutterVersionFromPubspecEnvironment', () { + group('when no pubspec.yaml is found', () { + test('throws an exception', () { + expect( + () => flutterVersionFromPubspecEnvironment( + packagePath: 'no/such/package', + ), + throwsA(isA()), + ); + }); + }); + + group('when no flutter version is specified', () { + test('returns null', () { + expect( + flutterVersionFromPubspecEnvironment( + packagePath: packageDirectory.path, + ), + isNull, + ); + }); + }); + + group('when a flutter version range is specified', () { + setUp(() { + pubspecFile.writeAsStringSync(''' +environment: + sdk: ^3.8.1 + flutter: ">=3.8.0 <4.0.0" +'''); + }); + + test('throws a VersionConstraintException', () { + expect( + () => flutterVersionFromPubspecEnvironment( + packagePath: packageDirectory.path, + ), + throwsA(isA()), + ); + }); + }); + + group('when a minimum version is specified', () { + setUp(() { + pubspecFile.writeAsStringSync(''' +environment: + sdk: ^3.8.1 + flutter: "^3.8.0" +'''); + }); + + test('throws a VersionConstraintException', () { + expect( + () => flutterVersionFromPubspecEnvironment( + packagePath: packageDirectory.path, + ), + throwsA(isA()), + ); + }); + }); + + group('when a flutter version is specified', () { + setUp(() { + pubspecFile.writeAsStringSync(''' +environment: + sdk: ^3.8.1 + flutter: 3.20.0 +'''); + }); + + test('returns the version', () { + expect( + flutterVersionFromPubspecEnvironment( + packagePath: packageDirectory.path, + ), + equals(Version(3, 20, 0)), + ); + }); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 69c5840e..45de9a44 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -401,6 +401,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.10.0" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" logging: dependency: transitive description: @@ -882,4 +890,4 @@ packages: source: hosted version: "2.2.2" sdks: - dart: ">=3.8.0 <4.0.0" + dart: ">=3.8.1 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index abbcfb93..0939f694 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,6 +5,7 @@ environment: workspace: - packages/artifact_proxy - packages/discord_gcp_alerts + - packages/flutter_version_resolver - packages/jwt - packages/redis_client - packages/scoped_deps