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>
126 lines
3.9 KiB
Dart
126 lines
3.9 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:test/test.dart';
|
|
import 'package:yaml/yaml.dart';
|
|
|
|
import '../../tool/src/images.dart';
|
|
|
|
// The images CI's checks never build: the deploy does, on the server, and
|
|
// a package missing from one failed it there (26 Oct 2026, `halkyon_core`
|
|
// kept out by `.dockerignore`, `halkyon_llm` never copied). Their list is
|
|
// `dartImages`, whose resolution tool/check.dart replays.
|
|
|
|
/// The sidecars' images, which copy single files of the repository's: kept
|
|
/// out of the build's context, they failed it (29 Oct 2026, Train's running
|
|
/// profile, `apps/train/` left out whole).
|
|
const _sidecars = [
|
|
'deploy/brouter/Dockerfile',
|
|
'deploy/maps/Dockerfile',
|
|
'deploy/photon/Dockerfile',
|
|
];
|
|
|
|
/// The packages [package] takes by path, and theirs, with itself: pub
|
|
/// resolves every workspace member's dev dependencies too, so an image
|
|
/// needs those as well.
|
|
Set<String> _needed(String package) {
|
|
final needed = <String>{};
|
|
final waiting = [package];
|
|
while (waiting.isNotEmpty) {
|
|
final each = waiting.removeLast();
|
|
if (!needed.add(each)) {
|
|
continue;
|
|
}
|
|
final pubspec =
|
|
loadYaml(File('$each/pubspec.yaml').readAsStringSync()) as YamlMap;
|
|
for (final section in ['dependencies', 'dev_dependencies']) {
|
|
final dependencies = pubspec[section] as YamlMap? ?? YamlMap();
|
|
for (final dependency in dependencies.values) {
|
|
if (dependency case {'path': final String path}) {
|
|
waiting.add(p.normalize(p.join(each, path)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return needed;
|
|
}
|
|
|
|
/// The folders the image's build stage copies in as they are named.
|
|
Set<String> _copied(String dockerfile) => {
|
|
for (final match in RegExp(
|
|
r'^COPY ((?:apps|packages)/\S+) \1$',
|
|
multiLine: true,
|
|
).allMatches(dockerfile))
|
|
match[1]!,
|
|
};
|
|
|
|
/// The members `tool/workspace_only.sh` keeps in the image's workspace.
|
|
List<String> _members(String dockerfile) => RegExp(
|
|
r'^RUN sh tool/workspace_only\.sh (.+)$',
|
|
multiLine: true,
|
|
).firstMatch(dockerfile)![1]!.split(' ');
|
|
|
|
/// The lines of `.dockerignore` that name a folder, without the slash.
|
|
Set<String> _ignored() => {
|
|
for (final line in File('.dockerignore').readAsLinesSync())
|
|
if (line.trim() case final folder
|
|
when folder.isNotEmpty && !folder.startsWith('#'))
|
|
folder.replaceFirst(RegExp(r'/$'), ''),
|
|
};
|
|
|
|
void main() {
|
|
for (final path in dartImages) {
|
|
group(path, () {
|
|
final dockerfile = File(path).readAsStringSync();
|
|
final package = _members(dockerfile).first;
|
|
|
|
test('builds the package it lies in', () {
|
|
expect(package, p.dirname(path));
|
|
});
|
|
|
|
test('copies every package the build resolves, and keeps each in the '
|
|
'workspace', () {
|
|
final needed = _needed(package);
|
|
expect(_copied(dockerfile), needed);
|
|
expect(_members(dockerfile).toSet(), needed);
|
|
});
|
|
|
|
test("keeps none of them out of the build's context", () {
|
|
final ignored = _ignored();
|
|
for (final each in _needed(package)) {
|
|
expect(
|
|
ignored.where(
|
|
(folder) => each == folder || p.isWithin(folder, each),
|
|
),
|
|
isEmpty,
|
|
reason: '.dockerignore leaves $each out',
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
for (final path in _sidecars) {
|
|
test("$path copies files that are in the build's context", () {
|
|
final ignored = _ignored();
|
|
final sources = [
|
|
for (final match in RegExp(
|
|
r'^COPY (?:--\S+ )*(\S+) \S+$',
|
|
multiLine: true,
|
|
).allMatches(File(path).readAsStringSync()))
|
|
match[1]!,
|
|
];
|
|
expect(sources, isNotEmpty);
|
|
for (final source in sources) {
|
|
expect(File(source).existsSync(), isTrue, reason: source);
|
|
expect(
|
|
ignored.where(
|
|
(folder) => source == folder || p.isWithin(folder, source),
|
|
),
|
|
isEmpty,
|
|
reason: '.dockerignore leaves $source out',
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|