Skip to content

Commit 74ab593

Browse files
authored
Expand the .ci.yaml and builder.json linter (#161991)
Closes flutter/flutter#161823. Other than the integration test included, example output looks like this: ```dart # Assuming you are in $ENGINE/src/flutter % dart tools/pkg/engine_build_configs/bin/check.dart --help -v, --verbose Enable noisier diagnostic output -h, --help Output usage information. --engine-src-path=</path/to/engine/src> (defaults to "/Users/matanl/Developer/flutter/engine/src") % dart tools/pkg/engine_build_configs/bin/check.dart ✅ Loaded build configs under ci/builders ✅ .ci.yaml at .ci.yaml is valid ✅ All configuration files are valid ✅ All builds within a builder are uniquely named ✅ All build names must have a conforming prefix ✅ All builder files conform to release_build standards ``` I allow-listed a single case that needs to be moved, but I think this provides value already.
1 parent 20b1565 commit 74ab593

File tree

6 files changed

+432
-51
lines changed

6 files changed

+432
-51
lines changed

engine/src/flutter/ci/check_build_configs.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,5 @@ DART="${DART_BIN}/dart"
4444
cd "$SCRIPT_DIR"
4545
"$DART" \
4646
"$SRC_DIR/flutter/tools/pkg/engine_build_configs/bin/check.dart" \
47-
"$SRC_DIR"
47+
"--engine-src-path=$SRC_DIR"
4848

engine/src/flutter/tools/pkg/engine_build_configs/bin/check.dart

Lines changed: 162 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,89 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5-
import 'dart:io' as io show Directory, exitCode, stderr;
5+
import 'dart:io' as io;
66

7+
import 'package:args/args.dart';
78
import 'package:engine_build_configs/engine_build_configs.dart';
9+
import 'package:engine_build_configs/src/ci_yaml.dart';
810
import 'package:engine_repo_tools/engine_repo_tools.dart';
11+
import 'package:meta/meta.dart';
912
import 'package:path/path.dart' as p;
1013
import 'package:platform/platform.dart';
14+
import 'package:source_span/source_span.dart';
15+
import 'package:yaml/yaml.dart' as y;
1116

1217
// Usage:
13-
// $ dart bin/check.dart [/path/to/engine/src]
18+
// $ dart bin/check.dart
19+
//
20+
// Or, for more options:
21+
// $ dart bin/check.dart --help
22+
23+
final _argParser =
24+
ArgParser()
25+
..addFlag('verbose', abbr: 'v', help: 'Enable noisier diagnostic output', negatable: false)
26+
..addFlag('help', abbr: 'h', help: 'Output usage information.', negatable: false)
27+
..addOption(
28+
'engine-src-path',
29+
valueHelp: '/path/to/engine/src',
30+
defaultsTo: Engine.tryFindWithin()?.srcDir.path,
31+
);
1432

1533
void main(List<String> args) {
16-
final String? engineSrcPath;
17-
if (args.isNotEmpty) {
18-
engineSrcPath = args[0];
19-
} else {
20-
engineSrcPath = null;
21-
}
34+
run(
35+
args,
36+
stderr: io.stderr,
37+
stdout: io.stdout,
38+
platform: const LocalPlatform(),
39+
setExitCode: (exitCode) {
40+
io.exitCode = exitCode;
41+
},
42+
);
43+
}
2244

23-
// Find the engine repo.
24-
final Engine engine;
25-
try {
26-
engine = Engine.findWithin(engineSrcPath);
27-
} catch (e) {
28-
io.stderr.writeln(e);
29-
io.exitCode = 1;
45+
@visibleForTesting
46+
void run(
47+
Iterable<String> args, {
48+
required Platform platform,
49+
required StringSink stderr,
50+
required StringSink stdout,
51+
required void Function(int) setExitCode,
52+
}) {
53+
y.yamlWarningCallback = (String message, [SourceSpan? span]) {};
54+
55+
final argResults = _argParser.parse(args);
56+
if (argResults.flag('help')) {
57+
stdout.writeln(_argParser.usage);
3058
return;
3159
}
3260

61+
final verbose = argResults.flag('verbose');
62+
void debugPrint(String output) {
63+
if (!verbose) {
64+
return;
65+
}
66+
stderr.writeln(output);
67+
}
68+
69+
void indentedPrint(Iterable<String> errors) {
70+
for (final error in errors) {
71+
stderr.writeln(' $error');
72+
}
73+
}
74+
75+
final supportsEmojis = !platform.isWindows || platform.environment.containsKey('WT_SESSION');
76+
final symbolSuccess = supportsEmojis ? '✅' : '✓';
77+
final symbolFailure = supportsEmojis ? '❌' : 'X';
78+
void statusPrint(String describe, {required bool success}) {
79+
stderr.writeln('${success ? symbolSuccess : symbolFailure} $describe');
80+
if (!success) {
81+
setExitCode(1);
82+
}
83+
}
84+
85+
final engine = Engine.fromSrcPath(argResults.option('engine-src-path')!);
86+
debugPrint('Initializing from ${p.relative(engine.srcDir.path)}');
87+
3388
// Find and parse the engine build configs.
3489
final io.Directory buildConfigsDir = io.Directory(
3590
p.join(engine.flutterDir.path, 'ci', 'builders'),
@@ -39,36 +94,112 @@ void main(List<String> args) {
3994
// Treat it as an error if no build configs were found. The caller likely
4095
// expected to find some.
4196
final Map<String, BuilderConfig> configs = loader.configs;
97+
98+
// We can't make further progress if we didn't find any configurations.
99+
statusPrint(
100+
'Loaded build configs under ${p.relative(buildConfigsDir.path)}',
101+
success: configs.isNotEmpty && loader.errors.isEmpty,
102+
);
42103
if (configs.isEmpty) {
43-
io.stderr.writeln('Error: No build configs found under ${buildConfigsDir.path}');
44-
io.exitCode = 1;
45104
return;
46105
}
47-
if (loader.errors.isNotEmpty) {
48-
loader.errors.forEach(io.stderr.writeln);
49-
io.exitCode = 1;
106+
indentedPrint(loader.errors);
107+
108+
// Find and parse the .ci.yaml configuration (for the engine).
109+
final CiConfig? ciConfig;
110+
{
111+
final String ciYamlPath = p.join(engine.flutterDir.path, '.ci.yaml');
112+
final String realCiYaml = io.File(ciYamlPath).readAsStringSync();
113+
final y.YamlNode yamlNode = y.loadYamlNode(realCiYaml, sourceUrl: Uri.file(ciYamlPath));
114+
final loadedConfig = CiConfig.fromYaml(yamlNode);
115+
116+
statusPrint('.ci.yaml at ${p.relative(ciYamlPath)} is valid', success: loadedConfig.valid);
117+
if (!loadedConfig.valid) {
118+
indentedPrint([loadedConfig.error!]);
119+
ciConfig = null;
120+
} else {
121+
ciConfig = loadedConfig;
122+
}
50123
}
51124

52125
// Check the parsed build configs for validity.
53126
final List<String> invalidErrors = checkForInvalidConfigs(configs);
54-
if (invalidErrors.isNotEmpty) {
55-
invalidErrors.forEach(io.stderr.writeln);
56-
io.exitCode = 1;
57-
}
127+
statusPrint('All configuration files are valid', success: invalidErrors.isEmpty);
128+
indentedPrint(invalidErrors);
58129

59130
// We require all builds within a builder config to be uniquely named.
60131
final List<String> duplicateErrors = checkForDuplicateConfigs(configs);
61-
if (duplicateErrors.isNotEmpty) {
62-
duplicateErrors.forEach(io.stderr.writeln);
63-
io.exitCode = 1;
64-
}
132+
statusPrint('All builds within a builder are uniquely named', success: duplicateErrors.isEmpty);
133+
indentedPrint(duplicateErrors);
65134

66135
// We require all builds to be named in a way that is understood by et.
67136
final List<String> buildNameErrors = checkForInvalidBuildNames(configs);
68-
if (buildNameErrors.isNotEmpty) {
69-
buildNameErrors.forEach(io.stderr.writeln);
70-
io.exitCode = 1;
137+
statusPrint('All build names must have a conforming prefix', success: buildNameErrors.isEmpty);
138+
indentedPrint(buildNameErrors);
139+
140+
// If we have a successfully parsed .ci.yaml, perform additional checks.
141+
if (ciConfig == null) {
142+
return;
143+
}
144+
145+
// We require that targets that have `properties: release_build: "true"`:
146+
// (1) Each sub-build produces artifacts (`archives: [...]`)
147+
// (2) Each sub-build does not have `tests: [ ... ]`
148+
final buildConventionErrors = <String>[];
149+
for (final MapEntry(key: _, value: target) in ciConfig.ciTargets.entries) {
150+
final config = loader.configs[target.properties.configName];
151+
if (target.properties.configName == null) {
152+
// * builder_cache targets do not have configuration files.
153+
debugPrint(' Skipping ${target.name}: No configuration file found');
154+
continue;
155+
}
156+
157+
// This would fail above during the general loading.
158+
if (config == null) {
159+
throw StateError('Unreachable');
160+
}
161+
162+
final configConventionErrors = <String>[];
163+
if (target.properties.isReleaseBuilder) {
164+
// If there is a global generators step, assume artifacts are uploaded from the generators.
165+
if (config.generators.isNotEmpty) {
166+
debugPrint(' Skipping ${target.name}: Has "generators": [ ... ] which could do anything');
167+
continue;
168+
}
169+
// Check each build: it must have "archives: [ ... ]" and NOT "tests: [ ... ]"
170+
for (final build in config.builds) {
171+
if (build.archives.isEmpty) {
172+
configConventionErrors.add('${build.name}: Does not have "archives: [ ... ]"');
173+
}
174+
if (build.archives.any((e) => e.includePaths.isEmpty)) {
175+
configConventionErrors.add(
176+
'${build.name}: Has an archive with an empty "include_paths": []',
177+
);
178+
}
179+
if (build.tests.isNotEmpty) {
180+
// TODO(matanlurey): https://github.com/flutter/flutter/issues/161990.
181+
if (target.properties.configName == 'windows_host_engine' &&
182+
build.name == r'ci\host_debug') {
183+
debugPrint(' Skipping: ${build.name}: Allow-listed during migration');
184+
} else {
185+
configConventionErrors.add('${build.name}: Includes "tests: [ ... ]"');
186+
}
187+
}
188+
}
189+
}
190+
191+
if (configConventionErrors.isNotEmpty) {
192+
buildConventionErrors.add(
193+
'${p.basename(config.path)} (${target.name}, release_build = ${target.properties.isReleaseBuilder}):',
194+
);
195+
buildConventionErrors.addAll(configConventionErrors.map((e) => ' $e'));
196+
}
71197
}
198+
statusPrint(
199+
'All builder files conform to release_build standards',
200+
success: buildConventionErrors.isEmpty,
201+
);
202+
indentedPrint(buildConventionErrors);
72203
}
73204

74205
// This check ensures that all the json files were deserialized without errors.

engine/src/flutter/tools/pkg/engine_build_configs/lib/src/ci_yaml.dart

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const String _nameField = 'name';
1818
const String _recipeField = 'recipe';
1919
const String _propertiesField = 'properties';
2020
const String _configNameField = 'config_name';
21+
const String _releaseBuildField = 'release_build';
2122

2223
/// A class containing the information deserialized from the .ci.yaml file.
2324
///
@@ -92,15 +93,15 @@ class CiTarget {
9293
return CiTarget._error(error);
9394
}
9495
final y.YamlMap targetMap = yaml;
95-
final String? name = _stringOfNode(targetMap.nodes[_nameField]);
96+
final String? name = targetMap.nodes[_nameField].readStringOrNull();
9697
if (name == null) {
9798
final String error = targetMap.span.message(
9899
'Expected map to contain a string value for key "$_nameField".',
99100
);
100101
return CiTarget._error(error);
101102
}
102103

103-
final String? recipe = _stringOfNode(targetMap.nodes[_recipeField]);
104+
final String? recipe = targetMap.nodes[_recipeField].readStringOrNull();
104105
if (recipe == null) {
105106
final String error = targetMap.span.message(
106107
'Expected map to contain a string value for key "$_recipeField".',
@@ -159,18 +160,23 @@ class CiTargetProperties {
159160
return CiTargetProperties._error(error);
160161
}
161162
final y.YamlMap propertiesMap = yaml;
162-
final String? configName = _stringOfNode(propertiesMap.nodes[_configNameField]);
163-
return CiTargetProperties._(configName: configName ?? '');
163+
return CiTargetProperties._(
164+
configName: propertiesMap.nodes[_configNameField].readStringOrNull(),
165+
isReleaseBuilder: propertiesMap.nodes[_releaseBuildField].readStringOrNull() == 'true',
166+
);
164167
}
165168

166-
CiTargetProperties._({required this.configName}) : error = null;
169+
CiTargetProperties._({required this.configName, required this.isReleaseBuilder}) : error = null;
167170

168-
CiTargetProperties._error(this.error) : configName = '';
171+
CiTargetProperties._error(this.error) : configName = '', isReleaseBuilder = false;
169172

170173
/// The name of the build configuration. If the containing [CiTarget] instance
171174
/// is using the engine_v2 recipes, then this name is the same as the name
172175
/// of the build config json file under ci/builders.
173-
final String configName;
176+
final String? configName;
177+
178+
/// Whether this is a release builder.
179+
final bool isReleaseBuilder;
174180

175181
/// An error message when this instance is invalid.
176182
final String? error;
@@ -179,16 +185,25 @@ class CiTargetProperties {
179185
late final bool valid = error == null;
180186
}
181187

182-
String? _stringOfNode(y.YamlNode? stringNode) {
183-
if (stringNode == null) {
184-
return null;
185-
}
186-
if (stringNode is! y.YamlScalar) {
187-
return null;
188-
}
189-
final y.YamlScalar stringScalar = stringNode;
190-
if (stringScalar.value is! String) {
191-
return null;
188+
/// Extensions for reading and partially validating typed values from YAML.
189+
extension on y.YamlNode? {
190+
// TODO(matanlurey): Much of this should really be an error but that also
191+
// predicates a lot more work put into .ci.yaml validation that is better
192+
// served by having a dedicated library:
193+
// https://github.com/flutter/flutter/issues/161971.
194+
195+
T? _readOrNull<T>() {
196+
final node = this;
197+
if (node is! y.YamlScalar) {
198+
return null;
199+
}
200+
final Object? value = node.value;
201+
if (value is! T) {
202+
return null;
203+
}
204+
return value;
192205
}
193-
return stringScalar.value as String;
206+
207+
/// Returns this node as a string if possible.
208+
String? readStringOrNull() => _readOrNull();
194209
}

engine/src/flutter/tools/pkg/engine_build_configs/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ dependencies:
2020
path: any
2121
platform: any
2222
process_runner: any
23+
source_span: any
2324
yaml: any
2425

2526
dev_dependencies:
2627
process_fakes: any
27-
source_span: any
2828
test: any

0 commit comments

Comments
 (0)