halkyon/tool/check.dart

433 lines
15 KiB
Dart
Raw Permalink Normal View History

// 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;
}