halkyon/test/deploy/images_test.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

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',
);
}
});
}
}