|
| 1 | +// Copyright 2013 The Flutter Authors. All rights reserved. |
| 2 | +// Use of this source code is governed by a BSD-style license that can be |
| 3 | +// found in the LICENSE file. |
| 4 | + |
| 5 | +import 'package:file/file.dart'; |
| 6 | +import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; |
| 7 | +import 'package:git/git.dart'; |
| 8 | +import 'package:path/path.dart' as p; |
| 9 | +import 'package:platform/platform.dart'; |
| 10 | +import 'package:pub_semver/pub_semver.dart'; |
| 11 | +import 'package:pubspec_parse/pubspec_parse.dart'; |
| 12 | + |
| 13 | +import 'common/core.dart'; |
| 14 | +import 'common/file_utils.dart'; |
| 15 | +import 'common/git_version_finder.dart'; |
| 16 | +import 'common/package_looping_command.dart'; |
| 17 | +import 'common/process_runner.dart'; |
| 18 | +import 'common/repository_package.dart'; |
| 19 | + |
| 20 | +/// A command to check that PRs don't violate repository best practices that |
| 21 | +/// have been established to avoid breakages that building and testing won't |
| 22 | +/// catch. |
| 23 | +class FederationSafetyCheckCommand extends PackageLoopingCommand { |
| 24 | + /// Creates an instance of the safety check command. |
| 25 | + FederationSafetyCheckCommand( |
| 26 | + Directory packagesDir, { |
| 27 | + ProcessRunner processRunner = const ProcessRunner(), |
| 28 | + Platform platform = const LocalPlatform(), |
| 29 | + GitDir? gitDir, |
| 30 | + }) : super( |
| 31 | + packagesDir, |
| 32 | + processRunner: processRunner, |
| 33 | + platform: platform, |
| 34 | + gitDir: gitDir, |
| 35 | + ); |
| 36 | + |
| 37 | + // A map of package name (as defined by the directory name of the package) |
| 38 | + // to a list of changed Dart files in that package, as Posix paths relative to |
| 39 | + // the package root. |
| 40 | + // |
| 41 | + // This only considers top-level packages, not subpackages such as example/. |
| 42 | + final Map<String, List<String>> _changedDartFiles = <String, List<String>>{}; |
| 43 | + |
| 44 | + // The set of *_platform_interface packages that will have public code changes |
| 45 | + // published. |
| 46 | + final Set<String> _modifiedAndPublishedPlatformInterfacePackages = <String>{}; |
| 47 | + |
| 48 | + // The set of conceptual plugins (not packages) that have changes. |
| 49 | + final Set<String> _changedPlugins = <String>{}; |
| 50 | + |
| 51 | + static const String _platformInterfaceSuffix = '_platform_interface'; |
| 52 | + |
| 53 | + @override |
| 54 | + final String name = 'federation-safety-check'; |
| 55 | + |
| 56 | + @override |
| 57 | + final String description = |
| 58 | + 'Checks that the change does not violate repository rules around changes ' |
| 59 | + 'to federated plugin packages.'; |
| 60 | + |
| 61 | + @override |
| 62 | + bool get hasLongOutput => false; |
| 63 | + |
| 64 | + @override |
| 65 | + Future<void> initializeRun() async { |
| 66 | + final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); |
| 67 | + final String baseSha = await gitVersionFinder.getBaseSha(); |
| 68 | + print('Validating changes relative to "$baseSha"\n'); |
| 69 | + for (final String path in await gitVersionFinder.getChangedFiles()) { |
| 70 | + // Git output always uses Posix paths. |
| 71 | + final List<String> allComponents = p.posix.split(path); |
| 72 | + final int packageIndex = allComponents.indexOf('packages'); |
| 73 | + if (packageIndex == -1) { |
| 74 | + continue; |
| 75 | + } |
| 76 | + final List<String> relativeComponents = |
| 77 | + allComponents.sublist(packageIndex + 1); |
| 78 | + // The package name is either the directory directly under packages/, or |
| 79 | + // the directory under that in the case of a federated plugin. |
| 80 | + String packageName = relativeComponents.removeAt(0); |
| 81 | + // Count the top-level plugin as changed. |
| 82 | + _changedPlugins.add(packageName); |
| 83 | + if (relativeComponents[0] == packageName || |
| 84 | + relativeComponents[0].startsWith('${packageName}_')) { |
| 85 | + packageName = relativeComponents.removeAt(0); |
| 86 | + } |
| 87 | + |
| 88 | + if (relativeComponents.last.endsWith('.dart')) { |
| 89 | + _changedDartFiles[packageName] ??= <String>[]; |
| 90 | + _changedDartFiles[packageName]! |
| 91 | + .add(p.posix.joinAll(relativeComponents)); |
| 92 | + } |
| 93 | + |
| 94 | + if (packageName.endsWith(_platformInterfaceSuffix) && |
| 95 | + relativeComponents.first == 'pubspec.yaml' && |
| 96 | + await _packageWillBePublished(path)) { |
| 97 | + _modifiedAndPublishedPlatformInterfacePackages.add(packageName); |
| 98 | + } |
| 99 | + } |
| 100 | + } |
| 101 | + |
| 102 | + @override |
| 103 | + Future<PackageResult> runForPackage(RepositoryPackage package) async { |
| 104 | + if (!isFlutterPlugin(package)) { |
| 105 | + return PackageResult.skip('Not a plugin.'); |
| 106 | + } |
| 107 | + |
| 108 | + if (!package.isFederated) { |
| 109 | + return PackageResult.skip('Not a federated plugin.'); |
| 110 | + } |
| 111 | + |
| 112 | + if (package.directory.basename.endsWith('_platform_interface')) { |
| 113 | + // As the leaf nodes in the graph, a published package interface change is |
| 114 | + // assumed to be correct, and other changes are validated against that. |
| 115 | + return PackageResult.skip( |
| 116 | + 'Platform interface changes are not validated.'); |
| 117 | + } |
| 118 | + |
| 119 | + // Uses basename to match _changedPackageFiles. |
| 120 | + final String basePackageName = package.directory.parent.basename; |
| 121 | + final String platformInterfacePackageName = |
| 122 | + '$basePackageName$_platformInterfaceSuffix'; |
| 123 | + final List<String> changedPlatformInterfaceFiles = |
| 124 | + _changedDartFiles[platformInterfacePackageName] ?? <String>[]; |
| 125 | + |
| 126 | + if (!_modifiedAndPublishedPlatformInterfacePackages |
| 127 | + .contains(platformInterfacePackageName)) { |
| 128 | + print('No published changes for $platformInterfacePackageName.'); |
| 129 | + return PackageResult.success(); |
| 130 | + } |
| 131 | + |
| 132 | + if (!changedPlatformInterfaceFiles |
| 133 | + .any((String path) => path.startsWith('lib/'))) { |
| 134 | + print('No public code changes for $platformInterfacePackageName.'); |
| 135 | + return PackageResult.success(); |
| 136 | + } |
| 137 | + |
| 138 | + // If the change would be flagged, but it appears to be a mass change |
| 139 | + // rather than a plugin-specific change, allow it with a warning. |
| 140 | + // |
| 141 | + // This is a tradeoff between safety and convenience; forcing mass changes |
| 142 | + // to be split apart is not ideal, and the assumption is that reviewers are |
| 143 | + // unlikely to accidentally approve a PR that is supposed to be changing a |
| 144 | + // single plugin, but touches other plugins (vs accidentally approving a |
| 145 | + // PR that changes multiple parts of a single plugin, which is a relatively |
| 146 | + // easy mistake to make). |
| 147 | + // |
| 148 | + // 3 is chosen to minimize the chances of accidentally letting something |
| 149 | + // through (vs 2, which could be a single-plugin change with one stray |
| 150 | + // change to another file accidentally included), while not setting too |
| 151 | + // high a bar for detecting mass changes. This can be tuned if there are |
| 152 | + // issues with false positives or false negatives. |
| 153 | + const int massChangePluginThreshold = 3; |
| 154 | + if (_changedPlugins.length >= massChangePluginThreshold) { |
| 155 | + logWarning('Ignoring potentially dangerous change, as this appears ' |
| 156 | + 'to be a mass change.'); |
| 157 | + return PackageResult.success(); |
| 158 | + } |
| 159 | + |
| 160 | + printError('Dart changes are not allowed to other packages in ' |
| 161 | + '$basePackageName in the same PR as changes to public Dart code in ' |
| 162 | + '$platformInterfacePackageName, as this can cause accidental breaking ' |
| 163 | + 'changes to be missed by automated checks. Please split the changes to ' |
| 164 | + 'these two packages into separate PRs.\n\n' |
| 165 | + 'If you believe that this is a false positive, please file a bug.'); |
| 166 | + return PackageResult.fail( |
| 167 | + <String>['$platformInterfacePackageName changed.']); |
| 168 | + } |
| 169 | + |
| 170 | + Future<bool> _packageWillBePublished( |
| 171 | + String pubspecRepoRelativePosixPath) async { |
| 172 | + final File pubspecFile = childFileWithSubcomponents( |
| 173 | + packagesDir.parent, p.posix.split(pubspecRepoRelativePosixPath)); |
| 174 | + final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); |
| 175 | + if (pubspec.publishTo == 'none') { |
| 176 | + return false; |
| 177 | + } |
| 178 | + |
| 179 | + final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); |
| 180 | + final Version? previousVersion = |
| 181 | + await gitVersionFinder.getPackageVersion(pubspecRepoRelativePosixPath); |
| 182 | + if (previousVersion == null) { |
| 183 | + // The plugin is new, so it will be published. |
| 184 | + return true; |
| 185 | + } |
| 186 | + return pubspec.version != previousVersion; |
| 187 | + } |
| 188 | +} |
0 commit comments