Skip to content

Commit a8e0129

Browse files
[flutter_plugin_tools] Add a federated PR safety check (flutter#4329)
Creates a new command to validate that PRs don't change platform interface packages and implementations at the same time, to try to prevent ecosystem-breaking changes. See flutter#89518 for context. Per the explanation in the issue, this has carve-outs for: - Changes to platform interfaces that aren't published (allowing for past uses cases such as making a substantive change to an implementation, and making minor adjustments to comments in the PI package based on those changes). - Things that look like bulk changes (e.g., a mass change to account for a new lint rule) Fixes flutter#89518
1 parent 42b9909 commit a8e0129

8 files changed

+523
-8
lines changed

.cirrus.yml

+5
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ task:
7474
format_script: ./script/tool_runner.sh format --fail-on-change
7575
pubspec_script: ./script/tool_runner.sh pubspec-check
7676
license_script: dart $PLUGIN_TOOL license-check
77+
- name: federated_safety
78+
# This check is only meaningful for PRs, as it validates changes
79+
# rather than state.
80+
only_if: $CIRRUS_PR != ""
81+
script: ./script/tool_runner.sh federation-safety-check
7782
- name: dart_unit_tests
7883
env:
7984
matrix:

script/tool/CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
- `native-test --android`, `--ios`, and `--macos` now fail plugins that don't
44
have unit tests, rather than skipping them.
5+
- Added a new `federation-safety-check` command to help catch changes to
6+
federated packages that have been done in such a way that they will pass in
7+
CI, but fail once the change is landed and published.
58

69
## 0.7.1
710

script/tool/lib/src/common/git_version_finder.dart

+3
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ class GitVersionFinder {
5858
return null;
5959
}
6060
final String fileContent = gitShow.stdout as String;
61+
if (fileContent.trim().isEmpty) {
62+
return null;
63+
}
6164
final String? versionString = loadYaml(fileContent)['version'] as String?;
6265
return versionString == null ? null : Version.parse(versionString);
6366
}

script/tool/lib/src/common/repository_package.dart

+6
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ class RepositoryPackage {
4747
/// The package's top-level pubspec.yaml.
4848
File get pubspecFile => directory.childFile('pubspec.yaml');
4949

50+
/// True if this appears to be a federated plugin package, according to
51+
/// repository conventions.
52+
bool get isFederated =>
53+
directory.parent.basename != 'packages' &&
54+
directory.basename.startsWith(directory.parent.basename);
55+
5056
/// Returns the Flutter example packages contained in the package, if any.
5157
Iterable<RepositoryPackage> getExamples() {
5258
final Directory exampleDirectory = directory.childDirectory('example');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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+
}

script/tool/lib/src/main.dart

+2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import 'build_examples_command.dart';
1313
import 'common/core.dart';
1414
import 'create_all_plugins_app_command.dart';
1515
import 'drive_examples_command.dart';
16+
import 'federation_safety_check_command.dart';
1617
import 'firebase_test_lab_command.dart';
1718
import 'format_command.dart';
1819
import 'license_check_command.dart';
@@ -49,6 +50,7 @@ void main(List<String> args) {
4950
..addCommand(BuildExamplesCommand(packagesDir))
5051
..addCommand(CreateAllPluginsAppCommand(packagesDir))
5152
..addCommand(DriveExamplesCommand(packagesDir))
53+
..addCommand(FederationSafetyCheckCommand(packagesDir))
5254
..addCommand(FirebaseTestLabCommand(packagesDir))
5355
..addCommand(FormatCommand(packagesDir))
5456
..addCommand(LicenseCheckCommand(packagesDir))

script/tool/lib/src/pubspec_check_command.dart

+2-8
Original file line numberDiff line numberDiff line change
@@ -210,17 +210,11 @@ class PubspecCheckCommand extends PackageLoopingCommand {
210210
// Returns true if [packageName] appears to be an implementation package
211211
// according to repository conventions.
212212
bool _isImplementationPackage(RepositoryPackage package) {
213-
// An implementation package should be in a group folder...
214-
final Directory parentDir = package.directory.parent;
215-
if (parentDir.path == packagesDir.path) {
213+
if (!package.isFederated) {
216214
return false;
217215
}
218216
final String packageName = package.directory.basename;
219-
final String parentName = parentDir.basename;
220-
// ... whose name is a prefix of the package name.
221-
if (!packageName.startsWith(parentName)) {
222-
return false;
223-
}
217+
final String parentName = package.directory.parent.basename;
224218
// A few known package names are not implementation packages; assume
225219
// anything else is. (This is done instead of listing known implementation
226220
// suffixes to allow for non-standard suffixes; e.g., to put several

0 commit comments

Comments
 (0)