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>
This commit is contained in:
parent
2ec58bfaee
commit
1ba532d6ef
1 changed files with 478 additions and 16 deletions
124
tool/src/images.dart
Normal file
124
tool/src/images.dart
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// The images built from the workspace, as tool/check.dart replays their
|
||||
// resolution: pub refuses in an image what it accepts on a laptop with Flutter
|
||||
// (the deploys of 4 Nov 2026: overrides of Flutter plugins, exit 69), and only
|
||||
// the deploy builds the images, on the server, after every check has passed.
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// The Dockerfiles whose build stage resolves the workspace with Dart's own
|
||||
/// image (`FROM dart:<version> AS build`), built by the deploy.
|
||||
const dartImages = [
|
||||
'apps/learn/halkyon_learn_server/Dockerfile',
|
||||
'apps/learn/halkyon_learn_curator/Dockerfile',
|
||||
'apps/train/halkyon_train_server/Dockerfile',
|
||||
'apps/todo/halkyon_todo_server/Dockerfile',
|
||||
];
|
||||
|
||||
/// One instruction of a Dockerfile's build stage that the replay follows.
|
||||
sealed class const BuildStep();
|
||||
|
||||
/// `COPY <sources…> <destination>`, from the build's context.
|
||||
final class const CopyStep(final List<String> sources, final String destination)
|
||||
extends BuildStep;
|
||||
|
||||
/// `RUN <command>`, through `sh -c`.
|
||||
final class const RunStep(final String command) extends BuildStep;
|
||||
|
||||
/// `WORKDIR <path>`.
|
||||
final class const WorkdirStep(final String path) extends BuildStep;
|
||||
|
||||
/// The resolution of an image: the Dart its build stage runs on, and the
|
||||
/// stage's steps up to and including `RUN dart pub get`.
|
||||
typedef ImageResolution = ({String dartVersion, List<BuildStep> steps});
|
||||
|
||||
final _from = RegExp(r'^FROM dart:(\S+) AS build$');
|
||||
|
||||
/// The build stage of [dockerfile] (its text) up to and including its
|
||||
/// `RUN dart pub get`. Throws a [FormatException] where it has no such stage,
|
||||
/// or an instruction the replay doesn't know comes before `pub get`, so that
|
||||
/// a changed Dockerfile fails the check rather than being replayed wrongly.
|
||||
ImageResolution imageResolution(String dockerfile) {
|
||||
String? version;
|
||||
final steps = <BuildStep>[];
|
||||
for (final line in _instructions(dockerfile)) {
|
||||
if (version == null) {
|
||||
version = _from.firstMatch(line)?[1];
|
||||
continue;
|
||||
}
|
||||
final [instruction, ...words] = line.split(RegExp(r'\s+'));
|
||||
final argument = line.substring(instruction.length).trim();
|
||||
switch (instruction) {
|
||||
case 'COPY' when words.length >= 2 && !argument.startsWith('--'):
|
||||
steps.add(CopyStep(words.sublist(0, words.length - 1), words.last));
|
||||
case 'RUN':
|
||||
steps.add(RunStep(argument));
|
||||
if (argument == 'dart pub get') {
|
||||
return (dartVersion: version, steps: steps);
|
||||
}
|
||||
case 'WORKDIR':
|
||||
steps.add(WorkdirStep(argument));
|
||||
default:
|
||||
throw FormatException(
|
||||
"The replay of an image's resolution doesn't know: $line",
|
||||
);
|
||||
}
|
||||
}
|
||||
throw FormatException(
|
||||
version == null
|
||||
? 'No build stage FROM dart:<version> AS build'
|
||||
: 'No RUN dart pub get in the build stage',
|
||||
);
|
||||
}
|
||||
|
||||
/// The Dockerfile's instructions, comments and blank lines left out, lines
|
||||
/// continued with a backslash joined.
|
||||
Iterable<String> _instructions(String dockerfile) sync* {
|
||||
var pending = '';
|
||||
for (final line in dockerfile.split('\n')) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
if (trimmed.endsWith(r'\')) {
|
||||
pending += '${trimmed.substring(0, trimmed.length - 1).trim()} ';
|
||||
continue;
|
||||
}
|
||||
yield pending + trimmed;
|
||||
pending = '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Where [step] puts each of [files] (the repository's files, paths relative
|
||||
/// to the build's context), relative to [workdir], as Docker's `COPY` does:
|
||||
/// a folder's content into the destination, a file into it where it ends in
|
||||
/// a slash or several sources are named, else as the destination itself.
|
||||
Map<String, String> copiedFiles(
|
||||
CopyStep step,
|
||||
Iterable<String> files, {
|
||||
String workdir = '/app',
|
||||
}) {
|
||||
final destination = p.posix.normalize(
|
||||
p.posix.join(workdir, step.destination),
|
||||
);
|
||||
final intoFolder =
|
||||
step.destination.endsWith('/') ||
|
||||
step.destination == '.' ||
|
||||
step.sources.length > 1;
|
||||
return {
|
||||
for (final source in step.sources.map(p.posix.normalize))
|
||||
for (final file in files)
|
||||
if (file == source)
|
||||
file: intoFolder
|
||||
? p.posix.join(destination, p.posix.basename(file))
|
||||
: destination
|
||||
else if (p.posix.isWithin(source, file))
|
||||
file: p.posix.join(destination, p.posix.relative(file, from: source)),
|
||||
};
|
||||
}
|
||||
|
||||
/// The name of the Dart SDK's archive for this machine, as Google's archive
|
||||
/// names it (`dartsdk-linux-x64-release.zip`); [version] is
|
||||
/// `Platform.version`, and [os] `Platform.operatingSystem`.
|
||||
String dartSdkArchive(String os, String version) {
|
||||
final arch = RegExp(r'"\w+_(\w+)"').firstMatch(version)?[1] ?? 'x64';
|
||||
return 'dartsdk-${os == 'macos' ? 'macos' : 'linux'}-$arch-release.zip';
|
||||
}
|
||||
Loading…
Reference in a new issue