halkyon/tool/check.dart
Tizian 「ティツィアーン」 1ba532d6ef
All checks were successful
CI / check (push) Successful in 45m37s
CI / deploy (push) Successful in 5m23s
build(check): the images' resolution replayed as the deploy builds them, before any push
Only the deploy builds the images, on the server, after every check
has passed, so an image that pub refuses cost a push each time (twice
on 4 Nov 2026). A step of tool/check.dart, right after the packages,
replays each Dockerfile's build stage up to its `dart pub get` from
the working tree's files in an empty folder, with Dart alone at the
image's version (downloaded once into ~/.cache/halkyon-check/, its
SHA-256 checked), offline, in an environment holding nothing of the
machine's Dart or Flutter: `dart run` from Flutter sets DART_ROOT,
through which pub found Flutter and accepted what the image refuses.

Checked on both failed deploys' states, which fail it as they did,
and on the fix, which passes. --images-only runs it alone, in
seconds. The list of images, dartImages, moves into
tool/src/images.dart, and a test keeps every Dockerfile building with
Dart on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-04 13:43:51 +01:00

433 lines
15 KiB
Dart

// 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<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(
'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=<commit>] '
'[--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<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);
}
}
/// 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<void> _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<String, String> _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<bool> _runIn(
String directory,
String command,
Map<String, String> 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<String> _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<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;
}