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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-23 15:14:23 +02:00
commit 3def5126a3
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 206 additions and 18 deletions

View file

@ -1,7 +1,7 @@
// The checks every push has to pass: the packages the lock names, 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).
// 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
@ -15,6 +15,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:args/args.dart';
import 'package:crypto/crypto.dart';
import 'src/check_files.dart';
@ -56,6 +57,8 @@ Future<void> main(List<String> arguments) async {
('flutter', ['pub', 'get', '--enforce-lockfile']),
], quiet: true);
await _generatedCode();
final own = (await _output('git', [
'ls-files',
'*.dart',
@ -92,6 +95,83 @@ Future<void> main(List<String> arguments) async {
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]).

View file

@ -1,6 +1,8 @@
/// What `tool/check.dart` checks, as pure functions its tests reach.
library;
import 'package:yaml/yaml.dart';
/// Code generators' output, which is never formatted by hand: Serverpod's
/// client packages from top to bottom (but for [_written]), a server's
/// generated sources and test tools, and gen-l10n's localizations.
@ -63,3 +65,37 @@ List<String> melosFilters({List<String>? touched}) => [
'--include-dependents',
],
];
/// The version [package] is locked at in [lock] (a `pubspec.lock`'s text),
/// or null where the lock doesn't name it.
String? lockedVersion(String lock, String package) => switch (loadYaml(lock)) {
{'packages': final Map<Object?, Object?> packages} =>
switch (packages[package]) {
{'version': final String version} => version,
_ => null,
},
_ => null,
};
/// The version of the Serverpod CLI that `serverpod version` reported in
/// [output], or null where it reported none.
String? serverpodCliVersion(String output) => RegExp(
r'^Serverpod version: (\S+)$',
multiLine: true,
).firstMatch(output)?.group(1);
/// How a file absent from the working tree shows in a [differingFiles]
/// snapshot.
const absentFile = 'absent';
/// The files whose content differs between the snapshots [before] and
/// [after], sorted. A snapshot maps each file that differs from the
/// committed one (relative to the repository's root) to a digest of its
/// content, or [absentFile]; a file it doesn't name is as committed.
List<String> differingFiles(
Map<String, String> before,
Map<String, String> after,
) => [
for (final path in {...before.keys, ...after.keys})
if (before[path] != after[path]) path,
]..sort();