// The checks every push has to pass: the packages the lock names, the images' // resolution, 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 // dart run tool/check.dart --images-only // the images' resolution alone, in seconds: after a // change to a Dockerfile, the root pubspec or the lock // // 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:archive/archive_io.dart'; import 'package:args/args.dart'; import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'src/check_files.dart'; import 'src/images.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( 'images-only', negatable: false, help: "Check only the images' resolution.", ) ..addFlag('help', abbr: 'h', negatable: false)) .parse(arguments); if (options.flag('help')) { stdout.writeln( 'Usage: dart run tool/check.dart [--changed-since=] ' '[--images-only]', ); 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 _images(); if (options.flag('images-only')) { stdout.writeln("✓ The images' resolution passed."); return; } 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); } } /// Fails the check unless every image of [dartImages] resolves its packages /// as its build stage does on the server: its steps up to `dart pub get` /// replayed in an empty folder from the working tree's files, with a Dart /// SDK of the image's version alone, without Flutter (pub refused the /// overrides of Flutter plugins there and accepted them here, 4 Nov 2026), /// offline from the pub cache the step before filled. Only the deploy builds /// the images, after every check; a failure there cost a push each time. Future _images() async { stdout.writeln('▸ Images'); final repository = Directory.current.path; final tracked = (await _output('git', [ 'ls-files', '-z', '--cached', '--others', '--exclude-standard', ])).split('\x00').where((path) => path.isNotEmpty).toList(); final work = await Directory.systemTemp.createTemp('halkyon-images-'); try { for (final dockerfile in dartImages) { final ImageResolution resolution; try { resolution = imageResolution(File(dockerfile).readAsStringSync()); } on FormatException catch (error) { stderr.writeln('✗ $dockerfile: ${error.message}'); exit(1); } final sdk = await _dartSdk(resolution.dartVersion); final app = Directory(p.join(work.path, p.dirname(dockerfile))); var workdir = '/app'; final environment = _imageEnvironment(sdk); for (final step in resolution.steps) { final ok = switch (step) { WorkdirStep(:final path) => () { workdir = p.posix.normalize(p.posix.join(workdir, path)); return true; }(), CopyStep() => () { for (final MapEntry(key: from, value: to) in copiedFiles( step, tracked, workdir: workdir, ).entries) { final source = File(p.join(repository, from)); if (source.existsSync()) { final target = File( p.join(app.path, p.relative(to, from: '/app')), )..parent.createSync(recursive: true); source.copySync(target.path); } } return true; }(), RunStep(:final command) => await _runIn( p.join(app.path, p.relative(workdir, from: '/app')), command == 'dart pub get' ? 'dart pub get --offline' : command, environment, ), }; if (!ok) { stderr.writeln( "✗ Images failed: $dockerfile's build stage, as the deploy builds " 'it, fails here.', ); exit(1); } } stdout.writeln(' $dockerfile'); } } finally { work.deleteSync(recursive: true); } } /// What a build stage's commands see: little but the SDK on the PATH, as in /// Dart's image. Nothing of the machine's Dart or Flutter passes: `dart run` /// from Flutter sets `DART_ROOT` to Flutter's Dart, through which pub finds /// Flutter and accepts what the image refuses (seen 4 Nov 2026), and /// `FLUTTER_ROOT` would do the same. Only the home and the pub cache, the /// temporary folder, the language and the proxies are kept. Map _imageEnvironment(String sdk) => { for (final name in [ 'HOME', 'PUB_CACHE', 'TMPDIR', 'LANG', 'http_proxy', 'https_proxy', 'no_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', ]) name: ?Platform.environment[name], 'PATH': '${p.join(sdk, 'bin')}:/usr/local/bin:/usr/bin:/bin', }; /// Runs [command] through `sh -c` in [directory] with [environment] alone, /// its output shown only should it fail; whether it succeeded. Future _runIn( String directory, String command, Map environment, ) async { Directory(directory).createSync(recursive: true); final result = await Process.run( 'sh', ['-c', command], workingDirectory: directory, environment: environment, includeParentEnvironment: false, ); if (result.exitCode != 0) { stderr ..writeln(' \$ $command') ..write(result.stdout) ..write(result.stderr); } return result.exitCode == 0; } /// The Dart SDK at [version] alone, as Dart's images hold it: kept in the /// user's cache (`~/.cache/halkyon-check/`), downloaded from Google's archive /// the first time and checked against the archive's SHA-256. Its folder. Future _dartSdk(String version) async { final home = Platform.environment['XDG_CACHE_HOME'] ?? p.join(Platform.environment['HOME']!, '.cache'); final folder = Directory(p.join(home, 'halkyon-check', 'dart-$version')); final sdk = p.join(folder.path, 'dart-sdk'); if (File(p.join(sdk, 'bin', 'dart')).existsSync()) { return sdk; } final name = dartSdkArchive(Platform.operatingSystem, Platform.version); final url = 'https://storage.googleapis.com/dart-archive/channels/stable/release/' '$version/sdk/$name'; stdout.writeln(' Dart $version alone, downloaded once: $url'); final archive = await http.get(Uri.parse(url)); final sum = await http.get(Uri.parse('$url.sha256sum')); if (archive.statusCode != 200 || sum.statusCode != 200) { stderr.writeln('✗ Images failed: Dart $version not downloaded ($url).'); exit(1); } if ('${sha256.convert(archive.bodyBytes)}' != sum.body.split(' ').first) { stderr.writeln("✗ Images failed: Dart $version doesn't match its SHA-256."); exit(1); } final partial = Directory('${folder.path}.partial'); if (partial.existsSync()) { partial.deleteSync(recursive: true); } await extractArchiveToDisk( ZipDecoder().decodeBytes(archive.bodyBytes), partial.path, ); // The archive's modes aren't kept by every extraction; the SDK's // executables are its bin folder's. await _output('chmod', [ '-R', 'u+x', p.join(partial.path, 'dart-sdk', 'bin'), ]); partial.renameSync(folder.path); return sdk; } /// 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; }