feat(cross): content-address the toolchain image by sysroot + toolset

The publish skip-on-exists keyed only on sysroot_key, which omits the baked
toolset (the Dockerfile's base image, apt list, and slim prune list). A toolset
change with an unchanged sysroot (e.g. adding build-essential) left the old
image in place — skip-on-exists matched and never rebuilt.

The published image's primary tag is now `<sysrootKey>-<toolsetHash>`, where the
toolset hash covers the emitted Dockerfile + .dockerignore (ToolchainImage.
tagFor/imageTag). An emitter/toolset change yields a new tag, so publish
rebuilds and re-points the aliases. The baked in-image paths stay keyed by
sysrootKey, so a consume build still cache-hits; the build matrix references the
moving :<os> alias, so it always gets the latest image.

Signed-off-by: Joel Winarske <joel.winarske@gmail.com>
This commit is contained in:
Joel Winarske
2026-06-19 17:57:54 -07:00
parent 4aa659fcbe
commit 802ebe5b6f
3 changed files with 84 additions and 17 deletions
+21 -17
View File
@@ -1042,9 +1042,9 @@ class CrossCommand extends Command<int> {
/// providers don't lay out a self-contained dir to bake.
int _emitDockerfile(CrossProfile profile, CrossTarget target) {
if (!_canBakeImage(profile)) return ExitCode.usage.code;
final (ctx, key) = _writeImageBuildContext(profile, target);
final (ctx, imageTag) = _writeImageBuildContext(profile, target);
final tag = 'emb-cross-${profile.targetTriple}:$key';
final tag = 'emb-cross-${profile.targetTriple}:$imageTag';
_logger
..info('Wrote ${p.join(ctx.path, "Dockerfile")}')
..info('Build: docker build -t $tag ${ctx.path}')
@@ -1075,17 +1075,20 @@ class CrossCommand extends Command<int> {
) {
final ctx = Directory(p.dirname(profile.targetSysroot));
final key = sysrootKey(target);
File(p.join(ctx.path, 'Dockerfile')).writeAsStringSync(
ToolchainImage.dockerfile(
triple: profile.targetTriple,
sysrootKey: key,
toolchainVersion: target.toolchainVersion,
),
final dockerfile = ToolchainImage.dockerfile(
triple: profile.targetTriple,
sysrootKey: key,
toolchainVersion: target.toolchainVersion,
);
File(
p.join(ctx.path, '.dockerignore'),
).writeAsStringSync(ToolchainImage.dockerignore());
return (ctx, key);
final dockerignore = ToolchainImage.dockerignore();
File(p.join(ctx.path, 'Dockerfile')).writeAsStringSync(dockerfile);
File(p.join(ctx.path, '.dockerignore')).writeAsStringSync(dockerignore);
// Content-address the image tag by the sysroot AND the baked toolset, so an
// emitter/toolset change yields a new tag (publish re-builds instead of
// serving a stale image). The baked paths stay keyed by sysrootKey, so a
// consume build still hits cache.
final imageTag = ToolchainImage.tagFor(key, dockerfile, dockerignore);
return (ctx, imageTag);
}
/// Emit, then build + push the toolchain image to a registry. Registry-
@@ -1113,15 +1116,16 @@ class CrossCommand extends Command<int> {
return ExitCode.unavailable.code;
}
final (ctx, key) = _writeImageBuildContext(profile, target);
final (ctx, imageTag) = _writeImageBuildContext(profile, target);
final plan = ImagePublishPlan(
tool: tool,
contextDir: ctx.path,
imagePrefix: imagePrefix,
// Always publish (and skip-check) the immutable content-addressed key as
// the primary tag, so a changed manifest is never masked by a moving
// alias; --tag values are pushed as additional aliases on top.
tags: [key, ...tags.where((t) => t != key)],
// Always publish (and skip-check) the content-addressed image tag
// (sysroot + toolset) as the primary tag, so a changed manifest or
// toolset is never masked by a moving alias; --tag values are pushed as
// additional aliases on top.
tags: [imageTag, ...tags.where((t) => t != imageTag)],
push: push,
);
+38
View File
@@ -1,3 +1,7 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
/// Emits a Dockerfile (+ `.dockerignore`) that bakes a resolved **arm-gnu**
/// cross toolchain + sysroot into an OCI image, so CI pulls a ready toolchain
/// instead of re-downloading and extracting it every run.
@@ -109,4 +113,38 @@ LABEL org.opencontainers.image.title="emb cross toolchain: $triple" \\
$prune
''';
}
/// Content-addressed image tag `<sysrootKey>-<toolset hash>`, where the
/// toolset hash covers [dockerfileContent] + [dockerignoreContent]. A
/// toolset/emitter change (new base image, apt list, prune list) yields a new
/// tag even when [sysrootKey] is unchanged, so a publish re-builds instead of
/// skipping and serving a stale image.
static String tagFor(
String sysrootKey,
String dockerfileContent,
String dockerignoreContent,
) {
final hash = sha256
.convert(utf8.encode('$dockerfileContent\n$dockerignoreContent'))
.toString()
.substring(0, 12);
return '$sysrootKey-$hash';
}
/// [tagFor] computed from this image's own emitted Dockerfile + dockerignore.
static String imageTag({
required String triple,
required String sysrootKey,
String? toolchainVersion,
String fromImage = defaultFrom,
}) => tagFor(
sysrootKey,
dockerfile(
triple: triple,
sysrootKey: sysrootKey,
toolchainVersion: toolchainVersion,
fromImage: fromImage,
),
dockerignore(),
);
}
@@ -40,6 +40,31 @@ void main() {
});
});
group('ToolchainImage.imageTag', () {
test('is <sysrootKey>-<12 hex toolset hash>', () {
final tag = ToolchainImage.imageTag(
triple: 'aarch64-none-linux-gnu',
sysrootKey: '809d8bb7bb71',
toolchainVersion: '12.3.rel1',
);
expect(tag, matches(RegExp(r'^809d8bb7bb71-[0-9a-f]{12}$')));
});
test('stable for same inputs; a toolset change rekeys, prefix kept', () {
String tag({String from = ToolchainImage.defaultFrom}) =>
ToolchainImage.imageTag(
triple: 't',
sysrootKey: 'KEY',
fromImage: from,
);
expect(tag(), tag()); // deterministic
// A different baked toolset (base image) -> different suffix, same key.
expect(tag(from: 'ubuntu:24.04'), isNot(tag()));
expect(tag().startsWith('KEY-'), isTrue);
expect(tag(from: 'ubuntu:24.04').startsWith('KEY-'), isTrue);
});
});
test('dockerignore keeps only toolchain + sysroot in the context', () {
final di = ToolchainImage.dockerignore();
expect(di, contains('*'));