diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index f43d2fcc9b..3e2ec3f4c9 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -6,6 +6,7 @@ - `xctest` now supports running macOS tests in addition to iOS - **Breaking change**: it now requires an `--ios` and/or `--macos` flag. - The tooling now runs in strong null-safe mode. +- `publish plugins` check against pub.dev to determine if a release should happen. - Modified the output format of `pubspec-check` and `xctest` ## 0.2.0 diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index 70ec75bc7b..622a1a3cb1 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -8,6 +8,7 @@ import 'dart:io' as io; import 'package:file/file.dart'; import 'package:git/git.dart'; +import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.dart'; @@ -18,6 +19,7 @@ import 'common/core.dart'; import 'common/git_version_finder.dart'; import 'common/plugin_command.dart'; import 'common/process_runner.dart'; +import 'common/pub_version_finder.dart'; @immutable class _RemoteInfo { @@ -49,7 +51,10 @@ class PublishPluginCommand extends PluginCommand { Print print = print, io.Stdin? stdinput, GitDir? gitDir, - }) : _print = print, + http.Client? httpClient, + }) : _pubVersionFinder = + PubVersionFinder(httpClient: httpClient ?? http.Client()), + _print = print, _stdin = stdinput ?? io.stdin, super(packagesDir, processRunner: processRunner, gitDir: gitDir) { argParser.addOption( @@ -131,6 +136,7 @@ class PublishPluginCommand extends PluginCommand { final Print _print; final io.Stdin _stdin; StreamSubscription<String>? _stdinSubscription; + final PubVersionFinder _pubVersionFinder; @override Future<void> run() async { @@ -182,6 +188,8 @@ class PublishPluginCommand extends PluginCommand { remoteForTagPush: remote, ); } + + _pubVersionFinder.httpClient.close(); await _finish(successful); } @@ -196,6 +204,7 @@ class PublishPluginCommand extends PluginCommand { _print('No version updates in this commit.'); return true; } + _print('Getting existing tags...'); final io.ProcessResult existingTagsResult = await baseGitDir.runCommand(<String>['tag', '--sort=-committerdate']); @@ -212,7 +221,6 @@ class PublishPluginCommand extends PluginCommand { .childFile(pubspecPath); final _CheckNeedsReleaseResult result = await _checkNeedsRelease( pubspecFile: pubspecFile, - gitVersionFinder: gitVersionFinder, existingTags: existingTags, ); switch (result) { @@ -271,7 +279,6 @@ class PublishPluginCommand extends PluginCommand { // Returns a [_CheckNeedsReleaseResult] that indicates the result. Future<_CheckNeedsReleaseResult> _checkNeedsRelease({ required File pubspecFile, - required GitVersionFinder gitVersionFinder, required List<String> existingTags, }) async { if (!pubspecFile.existsSync()) { @@ -293,19 +300,24 @@ Safe to ignore if the package is deleted in this commit. return _CheckNeedsReleaseResult.failure; } - // Get latest tagged version and compare with the current version. - // TODO(cyanglaz): Check latest version of the package on pub instead of git - // https://github.com/flutter/flutter/issues/81047 - - final String latestTag = existingTags.firstWhere( - (String tag) => tag.split('-v').first == pubspec.name, - orElse: () => ''); - if (latestTag.isNotEmpty) { - final String latestTaggedVersion = latestTag.split('-v').last; - final Version latestVersion = Version.parse(latestTaggedVersion); - if (pubspec.version! < latestVersion) { + // Check if the package named `packageName` with `version` has already published. + final Version version = pubspec.version!; + final PubVersionFinderResponse pubVersionFinderResponse = + await _pubVersionFinder.getPackageVersion(package: pubspec.name); + if (pubVersionFinderResponse.versions.contains(version)) { + final String tagsForPackageWithSameVersion = existingTags.firstWhere( + (String tag) => + tag.split('-v').first == pubspec.name && + tag.split('-v').last == version.toString(), + orElse: () => ''); + _print( + 'The version $version of ${pubspec.name} has already been published'); + if (tagsForPackageWithSameVersion.isEmpty) { _print( - 'The new version (${pubspec.version}) is lower than the current version ($latestVersion) for ${pubspec.name}.\nThis git commit is a revert, no release is tagged.'); + 'However, the git release tag for this version (${pubspec.name}-v$version) is not found. Please manually fix the tag then run the command again.'); + return _CheckNeedsReleaseResult.failure; + } else { + _print('skip.'); return _CheckNeedsReleaseResult.noRelease; } } diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index a2ea9816ea..c7832e0da1 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -13,6 +13,8 @@ import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/common/process_runner.dart'; import 'package:flutter_plugin_tools/src/publish_plugin_command.dart'; import 'package:git/git.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; @@ -427,6 +429,37 @@ void main() { }); test('can release newly created plugins', () async { + const Map<String, dynamic> httpResponsePlugin1 = <String, dynamic>{ + 'name': 'plugin1', + 'versions': <String>[], + }; + + const Map<String, dynamic> httpResponsePlugin2 = <String, dynamic>{ + 'name': 'plugin2', + 'versions': <String>[], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'plugin1.json') { + return http.Response(json.encode(httpResponsePlugin1), 200); + } else if (request.url.pathSegments.last == 'plugin2.json') { + return http.Response(json.encode(httpResponsePlugin2), 200); + } + return http.Response('', 500); + }); + final PublishPluginCommand command = PublishPluginCommand(packagesDir, + processRunner: processRunner, + print: (Object? message) => printedMessages.add(message.toString()), + stdinput: mockStdin, + httpClient: mockClient, + gitDir: gitDir); + + commandRunner = CommandRunner<void>( + 'publish_check_command', + 'Test for publish-check command.', + ); + commandRunner.addCommand(command); + // Non-federated final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); // federated @@ -446,7 +479,6 @@ void main() { containsAllInOrder(<String>[ 'Checking local repo...', 'Local repo is ready!', - 'Getting existing tags...', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'Running `pub publish ` in ${pluginDir2.path}...\n', 'Packages released: plugin1, plugin2', @@ -463,10 +495,50 @@ void main() { test('can release newly created plugins, while there are existing plugins', () async { + const Map<String, dynamic> httpResponsePlugin0 = <String, dynamic>{ + 'name': 'plugin0', + 'versions': <String>['0.0.1'], + }; + + const Map<String, dynamic> httpResponsePlugin1 = <String, dynamic>{ + 'name': 'plugin1', + 'versions': <String>[], + }; + + const Map<String, dynamic> httpResponsePlugin2 = <String, dynamic>{ + 'name': 'plugin2', + 'versions': <String>[], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'plugin0.json') { + return http.Response(json.encode(httpResponsePlugin0), 200); + } else if (request.url.pathSegments.last == 'plugin1.json') { + return http.Response(json.encode(httpResponsePlugin1), 200); + } else if (request.url.pathSegments.last == 'plugin2.json') { + return http.Response(json.encode(httpResponsePlugin2), 200); + } + return http.Response('', 500); + }); + final PublishPluginCommand command = PublishPluginCommand(packagesDir, + processRunner: processRunner, + print: (Object? message) => printedMessages.add(message.toString()), + stdinput: mockStdin, + httpClient: mockClient, + gitDir: gitDir); + + commandRunner = CommandRunner<void>( + 'publish_check_command', + 'Test for publish-check command.', + ); + commandRunner.addCommand(command); + // Prepare an exiting plugin and tag it createFakePlugin('plugin0', packagesDir); await gitDir.runCommand(<String>['add', '-A']); await gitDir.runCommand(<String>['commit', '-m', 'Add plugins']); + await gitDir.runCommand(<String>['tag', 'plugin0-v0.0.1']); + // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; @@ -489,7 +561,6 @@ void main() { containsAllInOrder(<String>[ 'Checking local repo...', 'Local repo is ready!', - 'Getting existing tags...', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'Running `pub publish ` in ${pluginDir2.path}...\n', 'Packages released: plugin1, plugin2', @@ -505,6 +576,36 @@ void main() { }); test('can release newly created plugins, dry run', () async { + const Map<String, dynamic> httpResponsePlugin1 = <String, dynamic>{ + 'name': 'plugin1', + 'versions': <String>[], + }; + + const Map<String, dynamic> httpResponsePlugin2 = <String, dynamic>{ + 'name': 'plugin2', + 'versions': <String>[], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'plugin1.json') { + return http.Response(json.encode(httpResponsePlugin1), 200); + } else if (request.url.pathSegments.last == 'plugin2.json') { + return http.Response(json.encode(httpResponsePlugin2), 200); + } + return http.Response('', 500); + }); + final PublishPluginCommand command = PublishPluginCommand(packagesDir, + processRunner: processRunner, + print: (Object? message) => printedMessages.add(message.toString()), + stdinput: mockStdin, + httpClient: mockClient, + gitDir: gitDir); + + commandRunner = CommandRunner<void>( + 'publish_check_command', + 'Test for publish-check command.', + ); + commandRunner.addCommand(command); // Non-federated final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); // federated @@ -527,7 +628,6 @@ void main() { 'Checking local repo...', 'Local repo is ready!', '=============== DRY RUN ===============', - 'Getting existing tags...', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'Tagging release plugin1-v0.0.1...', 'Pushing tag to upstream...', @@ -541,6 +641,37 @@ void main() { }); test('version change triggers releases.', () async { + const Map<String, dynamic> httpResponsePlugin1 = <String, dynamic>{ + 'name': 'plugin1', + 'versions': <String>[], + }; + + const Map<String, dynamic> httpResponsePlugin2 = <String, dynamic>{ + 'name': 'plugin2', + 'versions': <String>[], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'plugin1.json') { + return http.Response(json.encode(httpResponsePlugin1), 200); + } else if (request.url.pathSegments.last == 'plugin2.json') { + return http.Response(json.encode(httpResponsePlugin2), 200); + } + return http.Response('', 500); + }); + final PublishPluginCommand command = PublishPluginCommand(packagesDir, + processRunner: processRunner, + print: (Object? message) => printedMessages.add(message.toString()), + stdinput: mockStdin, + httpClient: mockClient, + gitDir: gitDir); + + commandRunner = CommandRunner<void>( + 'publish_check_command', + 'Test for publish-check command.', + ); + commandRunner.addCommand(command); + // Non-federated final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); // federated @@ -558,7 +689,6 @@ void main() { containsAllInOrder(<String>[ 'Checking local repo...', 'Local repo is ready!', - 'Getting existing tags...', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'Running `pub publish ` in ${pluginDir2.path}...\n', 'Packages released: plugin1, plugin2', @@ -600,7 +730,6 @@ void main() { containsAllInOrder(<String>[ 'Checking local repo...', 'Local repo is ready!', - 'Getting existing tags...', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'Running `pub publish ` in ${pluginDir2.path}...\n', 'Packages released: plugin1, plugin2', @@ -619,6 +748,37 @@ void main() { test( 'delete package will not trigger publish but exit the command successfully.', () async { + const Map<String, dynamic> httpResponsePlugin1 = <String, dynamic>{ + 'name': 'plugin1', + 'versions': <String>[], + }; + + const Map<String, dynamic> httpResponsePlugin2 = <String, dynamic>{ + 'name': 'plugin2', + 'versions': <String>[], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'plugin1.json') { + return http.Response(json.encode(httpResponsePlugin1), 200); + } else if (request.url.pathSegments.last == 'plugin2.json') { + return http.Response(json.encode(httpResponsePlugin2), 200); + } + return http.Response('', 500); + }); + final PublishPluginCommand command = PublishPluginCommand(packagesDir, + processRunner: processRunner, + print: (Object? message) => printedMessages.add(message.toString()), + stdinput: mockStdin, + httpClient: mockClient, + gitDir: gitDir); + + commandRunner = CommandRunner<void>( + 'publish_check_command', + 'Test for publish-check command.', + ); + commandRunner.addCommand(command); + // Non-federated final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); // federated @@ -636,7 +796,6 @@ void main() { containsAllInOrder(<String>[ 'Checking local repo...', 'Local repo is ready!', - 'Getting existing tags...', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'Running `pub publish ` in ${pluginDir2.path}...\n', 'Packages released: plugin1, plugin2', @@ -677,7 +836,6 @@ void main() { containsAllInOrder(<String>[ 'Checking local repo...', 'Local repo is ready!', - 'Getting existing tags...', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'The file at The pubspec file at ${pluginDir2.childFile('pubspec.yaml').path} does not exist. Publishing will not happen for plugin2.\nSafe to ignore if the package is deleted in this commit.\n', 'Packages released: plugin1', @@ -691,18 +849,48 @@ void main() { expect(processRunner.pushTagsArgs[2], 'plugin1-v0.0.2'); }); - test( - 'versions revert do not trigger releases. Also prints out warning message.', + test('Exiting versions do not trigger release, also prints out message.', () async { + const Map<String, dynamic> httpResponsePlugin1 = <String, dynamic>{ + 'name': 'plugin1', + 'versions': <String>['0.0.2'], + }; + + const Map<String, dynamic> httpResponsePlugin2 = <String, dynamic>{ + 'name': 'plugin2', + 'versions': <String>['0.0.2'], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'plugin1.json') { + return http.Response(json.encode(httpResponsePlugin1), 200); + } else if (request.url.pathSegments.last == 'plugin2.json') { + return http.Response(json.encode(httpResponsePlugin2), 200); + } + return http.Response('', 500); + }); + final PublishPluginCommand command = PublishPluginCommand(packagesDir, + processRunner: processRunner, + print: (Object? message) => printedMessages.add(message.toString()), + stdinput: mockStdin, + httpClient: mockClient, + gitDir: gitDir); + + commandRunner = CommandRunner<void>( + 'publish_check_command', + 'Test for publish-check command.', + ); + commandRunner.addCommand(command); + // Non-federated - final Directory pluginDir1 = - createFakePlugin('plugin1', packagesDir, version: '0.0.2'); + createFakePlugin('plugin1', packagesDir, version: '0.0.2'); // federated - final Directory pluginDir2 = createFakePlugin( - 'plugin2', packagesDir.childDirectory('plugin2'), + createFakePlugin('plugin2', packagesDir.childDirectory('plugin2'), version: '0.0.2'); await gitDir.runCommand(<String>['add', '-A']); await gitDir.runCommand(<String>['commit', '-m', 'Add plugins']); + await gitDir.runCommand(<String>['tag', 'plugin1-v0.0.2']); + await gitDir.runCommand(<String>['tag', 'plugin2-v0.0.2']); // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; @@ -713,54 +901,64 @@ void main() { containsAllInOrder(<String>[ 'Checking local repo...', 'Local repo is ready!', - 'Getting existing tags...', - 'Running `pub publish ` in ${pluginDir1.path}...\n', - 'Running `pub publish ` in ${pluginDir2.path}...\n', - 'Packages released: plugin1, plugin2', + 'The version 0.0.2 of plugin1 has already been published', + 'skip.', + 'The version 0.0.2 of plugin2 has already been published', + 'skip.', 'Done!' ])); - expect(processRunner.pushTagsArgs, isNotEmpty); - expect(processRunner.pushTagsArgs[0], 'push'); - expect(processRunner.pushTagsArgs[1], 'upstream'); - expect(processRunner.pushTagsArgs[2], 'plugin1-v0.0.2'); - expect(processRunner.pushTagsArgs[3], 'push'); - expect(processRunner.pushTagsArgs[4], 'upstream'); - expect(processRunner.pushTagsArgs[5], 'plugin2-v0.0.2'); - processRunner.pushTagsArgs.clear(); - printedMessages.clear(); + expect(processRunner.pushTagsArgs, isEmpty); + }); - final List<String> plugin1Pubspec = - pluginDir1.childFile('pubspec.yaml').readAsLinesSync(); - plugin1Pubspec[plugin1Pubspec.indexWhere( - (String element) => element.contains('version:'))] = 'version: 0.0.1'; - pluginDir1 - .childFile('pubspec.yaml') - .writeAsStringSync(plugin1Pubspec.join('\n')); - final List<String> plugin2Pubspec = - pluginDir2.childFile('pubspec.yaml').readAsLinesSync(); - plugin2Pubspec[plugin2Pubspec.indexWhere( - (String element) => element.contains('version:'))] = 'version: 0.0.1'; - pluginDir2 - .childFile('pubspec.yaml') - .writeAsStringSync(plugin2Pubspec.join('\n')); + test( + 'Exiting versions do not trigger release, but fail if the tags do not exist.', + () async { + const Map<String, dynamic> httpResponsePlugin1 = <String, dynamic>{ + 'name': 'plugin1', + 'versions': <String>['0.0.2'], + }; + + const Map<String, dynamic> httpResponsePlugin2 = <String, dynamic>{ + 'name': 'plugin2', + 'versions': <String>['0.0.2'], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'plugin1.json') { + return http.Response(json.encode(httpResponsePlugin1), 200); + } else if (request.url.pathSegments.last == 'plugin2.json') { + return http.Response(json.encode(httpResponsePlugin2), 200); + } + return http.Response('', 500); + }); + final PublishPluginCommand command = PublishPluginCommand(packagesDir, + processRunner: processRunner, + print: (Object? message) => printedMessages.add(message.toString()), + stdinput: mockStdin, + httpClient: mockClient, + gitDir: gitDir); + + commandRunner = CommandRunner<void>( + 'publish_check_command', + 'Test for publish-check command.', + ); + commandRunner.addCommand(command); + + // Non-federated + createFakePlugin('plugin1', packagesDir, version: '0.0.2'); + // federated + createFakePlugin('plugin2', packagesDir.childDirectory('plugin2'), + version: '0.0.2'); await gitDir.runCommand(<String>['add', '-A']); - await gitDir - .runCommand(<String>['commit', '-m', 'Update versions to 0.0.1']); - - await commandRunner - .run(<String>['publish-plugin', '--all-changed', '--base-sha=HEAD~']); - expect( - printedMessages, - containsAllInOrder(<String>[ - 'Checking local repo...', - 'Local repo is ready!', - 'Getting existing tags...', - 'The new version (0.0.1) is lower than the current version (0.0.2) for plugin1.\nThis git commit is a revert, no release is tagged.', - 'The new version (0.0.1) is lower than the current version (0.0.2) for plugin2.\nThis git commit is a revert, no release is tagged.', - 'Done!' - ])); - + await gitDir.runCommand(<String>['commit', '-m', 'Add plugins']); + // Immediately return 0 when running `pub publish`. + processRunner.mockPublishCompleteCode = 0; + mockStdin.readLineOutput = 'y'; + await expectLater( + () => commandRunner.run( + <String>['publish-plugin', '--all-changed', '--base-sha=HEAD~']), + throwsA(const TypeMatcher<ToolExit>())); expect(processRunner.pushTagsArgs, isEmpty); });