build(learn): the server's image holds every package it resolves
All checks were successful
CI / check (push) Successful in 33m55s
CI / deploy (push) Successful in 2m20s

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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-26 10:37:42 +01:00
commit 973063a225
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 100 additions and 5 deletions

View file

@ -1,7 +1,7 @@
# The Docker build context is the repository root. Nothing secret, local or bulky # The Docker build context is the repository root. Nothing secret, local or bulky
# goes into it: Learn's server's and curator's Dockerfiles copy their whole package # goes into it: Learn's server's and curator's Dockerfiles copy their whole package
# folders (and the server the module it builds on, with the module's client, which the # folders (and the server every package it resolves, which test/deploy/images_test.dart
# module's tests depend on). # holds to its pubspecs, so none of them may be left out here).
# Secrets and local state # Secrets and local state
**/config/passwords.yaml **/config/passwords.yaml
@ -27,7 +27,6 @@ apps/learn/halkyon_learn_client/
apps/learn/tool/ apps/learn/tool/
apps/todo/ apps/todo/
packages/halkyon_app/ packages/halkyon_app/
packages/halkyon_core/
packages/halkyon_interop/ packages/halkyon_interop/
packages/halkyon_keys/ packages/halkyon_keys/
packages/halkyon_ui/ packages/halkyon_ui/

View file

@ -17,12 +17,14 @@ COPY apps/learn/halkyon_learn_server apps/learn/halkyon_learn_server
COPY packages/halkyon_base_server packages/halkyon_base_server COPY packages/halkyon_base_server packages/halkyon_base_server
COPY packages/halkyon_base_client packages/halkyon_base_client COPY packages/halkyon_base_client packages/halkyon_base_client
COPY packages/halkyon_core packages/halkyon_core COPY packages/halkyon_core packages/halkyon_core
COPY packages/halkyon_llm packages/halkyon_llm
COPY packages/halkyon_oidc_server packages/halkyon_oidc_server COPY packages/halkyon_oidc_server packages/halkyon_oidc_server
COPY packages/halkyon_oidc_client packages/halkyon_oidc_client COPY packages/halkyon_oidc_client packages/halkyon_oidc_client
# The workspace root with only the server and the modules it builds on (halkyon_base, # The workspace root with only the server and the modules it builds on (halkyon_base,
# and the OpenID Connect module halkyon_base signs in with, ADR 0023), with # and the OpenID Connect module halkyon_base signs in with, ADR 0023), with
# halkyon_core, whose BIP-39 list halkyon_base takes a matching code from: neither the # halkyon_core, whose BIP-39 list halkyon_base takes a matching code from, and
# halkyon_llm, which it forwards the operator's assistants with: neither the
# other apps nor Learn's client package are needed to build the server, and the Flutter # other apps nor Learn's client package are needed to build the server, and the Flutter
# packages would need Flutter. The base module's client comes along only because the # packages would need Flutter. The base module's client comes along only because the
# module's tests depend on it (SRP's two halves, held to each other), and pub resolves # module's tests depend on it (SRP's two halves, held to each other), and pub resolves
@ -30,7 +32,7 @@ COPY packages/halkyon_oidc_client packages/halkyon_oidc_client
# client depends on it; the server never imports either. Everything else stays as the # client depends on it; the server never imports either. Everything else stays as the
# repository has it, above all the dependency overrides, which bring the server our # repository has it, above all the dependency overrides, which bring the server our
# fork of the offline sync package (fetched with the git the Dart image has). # fork of the offline sync package (fetched with the git the Dart image has).
RUN sh tool/workspace_only.sh apps/learn/halkyon_learn_server packages/halkyon_base_server packages/halkyon_base_client packages/halkyon_core packages/halkyon_oidc_server packages/halkyon_oidc_client RUN sh tool/workspace_only.sh apps/learn/halkyon_learn_server packages/halkyon_base_server packages/halkyon_base_client packages/halkyon_core packages/halkyon_llm packages/halkyon_oidc_server packages/halkyon_oidc_client
RUN dart pub get RUN dart pub get
WORKDIR /app/apps/learn/halkyon_learn_server WORKDIR /app/apps/learn/halkyon_learn_server
RUN dart build cli --target bin/main.dart --output build RUN dart build cli --target bin/main.dart --output build

View file

@ -0,0 +1,94 @@
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',
);
}
});
});
}
}