-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[flutter_plugin_tools] Add a federated PR safety check #4329
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| // Copyright 2013 The Flutter Authors. All rights reserved. | ||
| // Use of this source code is governed by a BSD-style license that can be | ||
| // found in the LICENSE file. | ||
|
|
||
| import 'package:file/file.dart'; | ||
| import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; | ||
| import 'package:git/git.dart'; | ||
| import 'package:path/path.dart' as p; | ||
| import 'package:platform/platform.dart'; | ||
| import 'package:pub_semver/pub_semver.dart'; | ||
| import 'package:pubspec_parse/pubspec_parse.dart'; | ||
|
|
||
| import 'common/core.dart'; | ||
| import 'common/file_utils.dart'; | ||
| import 'common/git_version_finder.dart'; | ||
| import 'common/package_looping_command.dart'; | ||
| import 'common/process_runner.dart'; | ||
| import 'common/repository_package.dart'; | ||
|
|
||
| /// A command to check that PRs don't violate repository best practices that | ||
| /// have been established to avoid breakages that building and testing won't | ||
| /// catch. | ||
| class FederationSafetyCheckCommand extends PackageLoopingCommand { | ||
| /// Creates an instance of the safety check command. | ||
| FederationSafetyCheckCommand( | ||
| Directory packagesDir, { | ||
| ProcessRunner processRunner = const ProcessRunner(), | ||
| Platform platform = const LocalPlatform(), | ||
| GitDir? gitDir, | ||
| }) : super( | ||
| packagesDir, | ||
| processRunner: processRunner, | ||
| platform: platform, | ||
| gitDir: gitDir, | ||
| ); | ||
|
|
||
| // A map of package name (as defined by the directory name of the package) | ||
| // to a list of changed Dart files in that package, as Posix paths relative to | ||
| // the package root. | ||
| // | ||
| // This only considers top-level packages, not subpackages such as example/. | ||
| final Map<String, List<String>> _changedDartFiles = <String, List<String>>{}; | ||
|
|
||
| // The set of *_platform_interface packages that will have public code changes | ||
| // published. | ||
| final Set<String> _modifiedAndPublishedPlatformInterfacePackages = <String>{}; | ||
|
|
||
| // The set of conceptual plugins (not packages) that have changes. | ||
| final Set<String> _changedPlugins = <String>{}; | ||
|
|
||
| static const String _platformInterfaceSuffix = '_platform_interface'; | ||
|
|
||
| @override | ||
| final String name = 'federation-safety-check'; | ||
|
|
||
| @override | ||
| final String description = | ||
| 'Checks that the change does not violate repository rules around changes ' | ||
| 'to federated plugin packages.'; | ||
|
|
||
| @override | ||
| bool get hasLongOutput => false; | ||
|
|
||
| @override | ||
| Future<void> initializeRun() async { | ||
| final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); | ||
| final String baseSha = await gitVersionFinder.getBaseSha(); | ||
| print('Validating changes relative to "$baseSha"\n'); | ||
| for (final String path in await gitVersionFinder.getChangedFiles()) { | ||
| // Git output always uses Posix paths. | ||
| final List<String> allComponents = p.posix.split(path); | ||
| final int packageIndex = allComponents.indexOf('packages'); | ||
| if (packageIndex == -1) { | ||
| continue; | ||
| } | ||
| final List<String> relativeComponents = | ||
| allComponents.sublist(packageIndex + 1); | ||
| // The package name is either the directory directly under packages/, or | ||
| // the directory under that in the case of a federated plugin. | ||
| String packageName = relativeComponents.removeAt(0); | ||
| // Count the top-level plugin as changed. | ||
| _changedPlugins.add(packageName); | ||
| if (relativeComponents[0] == packageName || | ||
| relativeComponents[0].startsWith('${packageName}_')) { | ||
| packageName = relativeComponents.removeAt(0); | ||
| } | ||
|
|
||
| if (relativeComponents.last.endsWith('.dart')) { | ||
| _changedDartFiles[packageName] ??= <String>[]; | ||
| _changedDartFiles[packageName]! | ||
| .add(p.posix.joinAll(relativeComponents)); | ||
| } | ||
|
|
||
| if (packageName.endsWith(_platformInterfaceSuffix) && | ||
| relativeComponents.first == 'pubspec.yaml' && | ||
| await _packageWillBePublished(path)) { | ||
| _modifiedAndPublishedPlatformInterfacePackages.add(packageName); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @override | ||
| Future<PackageResult> runForPackage(RepositoryPackage package) async { | ||
| if (!isFlutterPlugin(package)) { | ||
| return PackageResult.skip('Not a plugin.'); | ||
| } | ||
|
|
||
| if (package.directory.parent.basename == 'packages') { | ||
| return PackageResult.skip('Not a federated plugin.'); | ||
| } | ||
|
|
||
| if (package.directory.basename.endsWith('_platform_interface')) { | ||
| // As the leaf nodes in the graph, a published package interface change is | ||
| // assumed to be correct, and other changes are validated against that. | ||
| return PackageResult.skip( | ||
| 'Platform interface changes are not validated.'); | ||
| } | ||
|
|
||
| // Uses basename to match _changedPackageFiles. | ||
| final String basePackageName = package.directory.parent.basename; | ||
| final String platformInterfacePackageName = | ||
| '$basePackageName$_platformInterfaceSuffix'; | ||
| final List<String> changedPlatformInterfaceFiles = | ||
| _changedDartFiles[platformInterfacePackageName] ?? <String>[]; | ||
|
|
||
| if (!_modifiedAndPublishedPlatformInterfacePackages | ||
| .contains(platformInterfacePackageName)) { | ||
| print('No published changes for $platformInterfacePackageName.'); | ||
| return PackageResult.success(); | ||
| } | ||
|
|
||
| if (!changedPlatformInterfaceFiles | ||
| .any((String path) => path.startsWith('lib/'))) { | ||
| print('No public code changes for $platformInterfacePackageName.'); | ||
| return PackageResult.success(); | ||
| } | ||
|
|
||
| // If the change would be flagged, but it appears to be a mass change | ||
| // rather than a plugin-specific change, allow it with a warning. | ||
| // | ||
| // This is a tradeoff between safety and convenience; forcing mass changes | ||
| // to be split apart is not ideal, and the assumption is that reviewers are | ||
| // unlikely to accidentally approve a PR that is supposed to be changing a | ||
| // single plugin, but touches other plugins (vs accidentally approving a | ||
| // PR that changes multiple parts of a single plugin, which is a relatively | ||
| // easy mistake to make). | ||
| // | ||
| // 3 is chosen to minimize the chances of accidentally letting something | ||
| // through (vs 2, which could be a single-plugin change with one stray | ||
| // change to another file accidentally included), while not setting too | ||
| // high a bar for detecting mass changes. This can be tuned if there are | ||
| // issues with false positives or false negatives. | ||
| const int massChangePluginThreshold = 3; | ||
| if (_changedPlugins.length >= massChangePluginThreshold) { | ||
| logWarning('Ignoring potentially dangerous change, as this appears ' | ||
| 'to be a mass change.'); | ||
| return PackageResult.success(); | ||
| } | ||
|
|
||
| printError('Dart changes are not allowed to other packages in foo in the ' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. foo?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Turns out that when writing the error message's test first, and copy/pasting it to the implementation rather than rewriting it, it's a good idea to remember to swap the variables in. |
||
| 'same PR as changes to public Dart code in foo_platform_interface, ' | ||
| 'as this can cause accidental breaking changes to be missed by ' | ||
| 'automated checks. Please move these changes to a separate PR.\n\n' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. s/move these changes/split the changes to X and Y into separate PRs
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. |
||
| 'If you believe that this is a false positive, please file a bug.'); | ||
| return PackageResult.fail( | ||
| <String>['$platformInterfacePackageName changed.']); | ||
| } | ||
|
|
||
| Future<bool> _packageWillBePublished( | ||
| String pubspecRepoRelativePosixPath) async { | ||
| final File pubspecFile = childFileWithSubcomponents( | ||
| packagesDir.parent, p.posix.split(pubspecRepoRelativePosixPath)); | ||
| final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); | ||
| if (pubspec.publishTo == 'none') { | ||
| return false; | ||
| } | ||
|
|
||
| final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); | ||
| final Version? previousVersion = | ||
| await gitVersionFinder.getPackageVersion(pubspecRepoRelativePosixPath); | ||
| if (previousVersion == null) { | ||
| // The plugin is new, so it will be published. | ||
| return true; | ||
| } | ||
| return pubspec.version != previousVersion; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems like something we might want to turn into a function since the it has very clear semantics whose details could potentially change in the future. Something like
package.isFederated.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.