halkyon/test/deploy/images_test.dart
Tizian 「ティツィアーン」 973063a225
All checks were successful
CI / check (push) Successful in 33m55s
CI / deploy (push) Successful in 2m20s
build(learn): the server's image holds every package it resolves
The deploy of 46be9983 failed while building the server's image: the
Dockerfile copies halkyon_core since ADR 0021's step 1 (a060fbc2), but
.dockerignore still left it out of the build's context, and halkyon_llm,
which the module forwards the operator's assistants with since step 2,
was never copied at all. Nothing was switched: the instance kept running
v0.20.0's server.

CI never builds the images (its runner has no Docker, by design), so
test/deploy/images_test.dart now holds each Dockerfile to its package's
path dependencies, dev dependencies included as pub resolves them: every
one copied, kept in the narrowed workspace, and not left out by
.dockerignore. It failed on both before this fix. Built locally since:
the server's image builds.

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

94 lines
3 KiB
Dart

import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
import 'package:yaml/yaml.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).
const _dockerfiles = [
'apps/learn/halkyon_learn_server/Dockerfile',
'apps/learn/halkyon_learn_curator/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 _dockerfiles) {
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',
);
}
});
});
}
}