// 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 main(List 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=]', ); 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 _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> _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?> _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) 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 _step( String name, List<(String, List)> 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 _run( String executable, List 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 _output(String executable, List 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; }