halkyon/tool/check.dart
Tizian 「ティツィアーン」 3def5126a3
build(check): the checks compare the generated code, as CI did alone
On 23 Oct a push went red in CI because an endpoint's changed comment
hadn't been generated into the module's client: only CI compared the
committed generated code with the generator's output. tool/check.dart now
runs every server's generation (Melos' `generate`, about 15 s) after
fetching the packages and fails, naming the files, where the working
tree's generated code changed by it; the files stay written anew, to be
committed. It compares the files that differ from the committed ones
before and after by their content, so uncommitted changes count as they
do everywhere else in the checks.

It refuses a Serverpod CLI at another version than the lock holds
Serverpod at (`dart install serverpod_cli@<version>`), and the root's
tests hold CI's SERVERPOD_VERSION to the lock. CI keeps installing the
CLI at that version and puts it on the job's PATH; its own comparison
step goes, as the checks run it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 17:48:31 +02:00

247 lines
8 KiB
Dart

// The checks every push has to pass: the packages the lock names, the
// generated code, formatting, static analysis (infos are fatal too), and every
// package's tests. The pre-push hook (tool/pre-push) runs it, and so does CI on
// every push (.forgejo/workflows/ci.yaml).
//
// dart run tool/check.dart everything
// dart run tool/check.dart --changed-since=origin/main
// the tests of the packages changed since then (in
// the working tree too), and of what depends on them
//
// The tests run through Melos' scripts (`test:dart`, `test:flutter`, the root
// pubspec), Melos itself a dependency of the root.
// It checks the working tree, so uncommitted changes count as well.
import 'dart:convert';
import 'dart:io';
import 'package:args/args.dart';
import 'package:crypto/crypto.dart';
import 'src/check_files.dart';
Future<void> main(List<String> arguments) async {
final options =
(ArgParser()
..addOption(
'changed-since',
help:
'Test only the packages changed since this commit, and those '
'depending on them.',
)
..addFlag('help', abbr: 'h', negatable: false))
.parse(arguments);
if (options.flag('help')) {
stdout.writeln(
'Usage: dart run tool/check.dart [--changed-since=<commit>]',
);
return;
}
final changedSince = options.option('changed-since');
Directory.current = (await _output('git', [
'rev-parse',
'--show-toplevel',
])).trim();
if ((await Process.run('git', ['diff', '--quiet', 'HEAD'])).exitCode != 0) {
stdout.writeln(
'Note: the working tree has uncommitted changes; they are checked too.',
);
}
// The packages the checked-out pubspecs and lock name, fetched first: after
// a merge or a switch of branch the workspace's resolution can still be the
// one from before, and the analysis then failed on packages it couldn't
// find (2026-10-13, 2026-10-17). The lock decides (--enforce-lockfile), so a
// check never changes it.
await _step('Packages', [
('flutter', ['pub', 'get', '--enforce-lockfile']),
], quiet: true);
await _generatedCode();
final own = (await _output('git', [
'ls-files',
'*.dart',
])).split('\n').where(isOwnDartFile).toList();
await _step('Formatting', [
('dart', ['format', '--output=none', '--set-exit-if-changed', ...own]),
]);
await _step('Analysis', [
('dart', ['analyze', '--fatal-infos']),
]);
await _step('Tools', [
('dart', ['test', 'test/']),
]);
// The sync tests need `serverpod start` running (`requires-server`); run
// them by hand with `flutter test -t requires-server` in Learn's app.
final touched = changedSince == null
? null
: await _packagesChangedSince(changedSince);
if (touched != null && touched.isEmpty) {
stdout
..writeln('▸ Tests')
..writeln('No package changed since $changedSince.');
} else {
final filters = melosFilters(touched: touched);
await _step('Tests', [
('dart', ['run', 'melos', 'run', 'test:dart', ...filters]),
('dart', ['run', 'melos', 'run', 'test:flutter', ...filters]),
]);
}
stdout.writeln('✓ All checks passed.');
}
/// Fails the check unless the generated code (each server's
/// `lib/src/generated/` and test tools, its client package's
/// `lib/src/protocol/`) is what Serverpod's generator makes of the models and
/// endpoints now: it is committed, and a model or endpoint changed without
/// generating again failed only in CI before (23 Oct 2026). Every server
/// package, the apps' and the shared module, through Melos' `generate`
/// script, with the CLI at the version the lock holds Serverpod at, as CI
/// installs it. What it writes stays in the working tree, to be committed.
Future<void> _generatedCode() async {
stdout.writeln('▸ Generated code');
final wanted = lockedVersion(
File('pubspec.lock').readAsStringSync(),
'serverpod',
);
final ProcessResult cli;
try {
cli = await Process.run('serverpod', ['--no-analytics', 'version']);
} on ProcessException {
stderr.writeln(
"✗ The Serverpod CLI isn't on the PATH: "
'dart install serverpod_cli@$wanted',
);
exit(1);
}
final found = serverpodCliVersion('${cli.stdout}');
if (wanted == null || found != wanted) {
stderr.writeln(
'✗ The Serverpod CLI is at ${found ?? 'an unknown version'}, the lock '
'holds Serverpod at $wanted: dart install serverpod_cli@$wanted',
);
exit(1);
}
final before = await _uncommittedFiles();
final code = await _run('dart', [
'run',
'melos',
'run',
'generate',
], quiet: true);
if (code != 0) {
stderr.writeln('✗ Generated code failed (serverpod generate).');
exit(code);
}
final differing = differingFiles(before, await _uncommittedFiles());
if (differing.isNotEmpty) {
stderr
..writeln(
'✗ The generated code differs from what the generator makes of the '
'models and endpoints now; it has been written anew, commit it:',
)
..writeAll(differing.map((path) => ' $path\n'));
exit(1);
}
}
/// The working tree's files that differ from the committed ones, untracked
/// files not ignored among them, each with a digest of its content, or
/// [absentFile] where it was deleted.
Future<Map<String, String>> _uncommittedFiles() async {
final paths = (await _output('git', [
'ls-files',
'--modified',
'--deleted',
'--others',
'--exclude-standard',
'-z',
])).split('\x00').where((path) => path.isNotEmpty).toSet();
return {
for (final path in paths)
path: switch (File(path)) {
final file when file.existsSync() =>
'${sha256.convert(file.readAsBytesSync())}',
_ => absentFile,
},
};
}
/// The packages changed since [commit], in the working tree too (committed
/// or not, and files new to git), or null for every package
/// ([packagesTouched]).
Future<List<String>?> _packagesChangedSince(String commit) async {
final paths = [
...(await _output('git', ['diff', '--name-only', commit])).split('\n'),
...(await _output('git', [
'ls-files',
'--others',
'--exclude-standard',
])).split('\n'),
].where((path) => path.isNotEmpty);
final root = Directory.current.path;
final packages = {
for (final package in jsonDecode(
await _output('dart', ['run', 'melos', 'list', '--json']),
) as List<Object?>)
if (package case {'name': final String name, 'location': final String at})
name: at.substring(root.length + 1),
};
return packagesTouched(paths, packages);
}
/// Runs [commands] one after another as the step [name], and ends the check
/// at the first that fails, with its exit code.
Future<void> _step(
String name,
List<(String, List<String>)> commands, {
bool quiet = false,
}) async {
stdout.writeln('▸ $name');
for (final (executable, arguments) in commands) {
final code = await _run(executable, arguments, quiet: quiet);
if (code != 0) {
stderr.writeln('✗ $name failed ($executable ${arguments.first}).');
exit(code);
}
}
}
/// Runs [executable] and returns its exit code; its output is passed on as
/// it comes, or if [quiet] only should it fail.
Future<int> _run(
String executable,
List<String> arguments, {
bool quiet = false,
}) async {
if (quiet) {
final result = await Process.run(executable, arguments);
if (result.exitCode != 0) {
stderr
..write(result.stdout)
..write(result.stderr);
}
return result.exitCode;
}
final process = await Process.start(
executable,
arguments,
mode: ProcessStartMode.inheritStdio,
);
return await process.exitCode;
}
/// What [executable] writes, failing the check if it fails.
Future<String> _output(String executable, List<String> arguments) async {
final result = await Process.run(executable, arguments);
if (result.exitCode != 0) {
stderr.write(result.stderr);
exit(result.exitCode);
}
return result.stdout as String;
}