deploy: the compose file names its own release, and the cut moves it with the version
Gates / frontend (push) Successful in 2m41s
Gates / test (push) Successful in 3m2s
Gates / test-aarch64 (push) Successful in 8m23s
Gates / package (push) Successful in 4m29s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 16m14s

This commit is contained in:
2026-10-20 11:46:13 +02:00
parent ea9f16a989
commit aa8c9e28af
7 changed files with 231 additions and 71 deletions
+180 -48
View File
@@ -447,6 +447,45 @@ fn rewriteZonVersion(arena: Allocator, source: []const u8, version: []const u8)
}, 0);
}
/// The compose file a user fetches from a release tag. Its image default names
/// that release, so `docker compose up -d` from the tag runs the tag's image and
/// `NXDNS_VERSION` stays an override, never a requirement.
const compose_path = "deploy/docker/compose.yaml";
const compose_image_prefix = "image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-";
/// The byte range of `<version>` in the image line
/// `image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-<version>}}`.
/// Only a line that starts with that text, after its indentation, is the image:
/// the same text in a comment or after another key pins nothing. The file must
/// hold exactly one such line, or the rewrite would have to guess.
fn findComposeVersion(source: []const u8) error{ NoComposeVersion, ManyComposeVersions }!VersionSpan {
var found: ?VersionSpan = null;
var offset: usize = 0;
var lines = std.mem.splitScalar(u8, source, '\n');
while (lines.next()) |line| {
const line_start = offset;
offset += line.len + 1;
const indent = line.len - std.mem.trimStart(u8, line, " ").len;
if (!std.mem.startsWith(u8, line[indent..], compose_image_prefix)) continue;
if (found != null) return error.ManyComposeVersions;
const start = indent + compose_image_prefix.len;
const rest = std.mem.trimEnd(u8, line[start..], " \r");
if (rest.len <= "}}".len or !std.mem.endsWith(u8, rest, "}}")) return error.NoComposeVersion;
const version = rest[0 .. rest.len - "}}".len];
if (std.mem.indexOfAny(u8, version, "{}$ ") != null) return error.NoComposeVersion;
found = .{ .start = line_start + start, .end = line_start + start + version.len };
}
return found orelse error.NoComposeVersion;
}
fn rewriteComposeVersion(arena: Allocator, source: []const u8, version: []const u8) ![]u8 {
const at = try findComposeVersion(source);
return std.mem.concat(arena, u8, &.{ source[0..at.start], version, source[at.end..] });
}
/// The primary certificate fingerprint of a `git verify-tag --raw` VALIDSIG
/// line: its LAST field. The line is
///
@@ -2243,9 +2282,9 @@ fn cut(ctx: *Ctx, kind_text: []const u8) !void {
const token = checked.authorization orelse return CheckFailed;
// The one commit this cut makes is assembled in three moves, in this order
// and no other: the manifest declares the version, the pin stage builds that
// version and writes its hashes into `flake.nix`, and the commit takes both
// files. `verify-dist` refuses a build whose `-Dversion-string` disagrees
// and no other: the manifest and the compose file declare the version, the
// pin stage builds that version and writes its hashes into `flake.nix`, and
// the commit takes all three files. `verify-dist` refuses a build whose `-Dversion-string` disagrees
// with the manifest, so the pins cannot be computed before the bump; and the
// pins belong to the commit, so the commit cannot be made before the pins.
// Both of these run before the manifest is rewritten: they can refuse, and
@@ -2258,7 +2297,11 @@ fn cut(ctx: *Ctx, kind_text: []const u8) !void {
};
defer environ.deinit();
if (bump_needed) try writeZonVersion(ctx, version, zon_source);
if (bump_needed) {
const compose = try composeWithVersion(ctx, version);
try writeZonVersion(ctx, version, zon_source);
try writeComposeVersion(ctx, version, compose);
}
try pinStage(ctx, version, &environ, zig_exe);
try commitBump(ctx, version, bump_needed);
@@ -2942,11 +2985,50 @@ fn verifyOriginTag(ctx: *Ctx, tag: []const u8, tag_ref: []const u8, commit: []co
ctx.pass("origin-tag", "{s} ({s}) on origin is this tool's own signed tag at {s}", .{ tag, object, commit });
}
/// Rewrites `build.zig.zon` and commits it, and nothing else.
/// The two files the bump commit may touch, and the only two. `flake.nix` joins
/// The files the bump commit may touch, and the only ones. `flake.nix` joins
/// `build.zig.zon` because the pin stage writes the release hashes into it
/// between the manifest write and the commit.
const bump_paths = [_][]const u8{ "build.zig.zon", "flake.nix" };
/// between the manifest write and the commit; the compose file joins because
/// its image default is the release a user of the tag gets.
const bump_paths = [_][]const u8{ "build.zig.zon", "flake.nix", compose_path };
/// Replaces `path` with `bytes` atomically: a cut interrupted mid-write must not
/// leave a half-written file that neither zig, compose nor a re-run can parse.
fn replaceFile(ctx: *Ctx, path: []const u8, bytes: []const u8) !void {
var atomic = Io.Dir.cwd().createFileAtomic(ctx.io, path, .{ .replace = true }) catch |err| {
ctx.soft("bump", "cannot open {s} for an atomic replace: {t}", .{ path, err });
return CheckFailed;
};
defer atomic.deinit(ctx.io);
atomic.file.writeStreamingAll(ctx.io, bytes) catch |err| {
ctx.soft("bump", "cannot write {s}: {t}", .{ path, err });
return CheckFailed;
};
atomic.file.sync(ctx.io) catch |err| {
ctx.soft("bump", "cannot flush {s} to disk: {t}", .{ path, err });
return CheckFailed;
};
atomic.replace(ctx.io) catch |err| {
ctx.soft("bump", "cannot replace {s}: {t}", .{ path, err });
return CheckFailed;
};
}
/// Read back from disk rather than trusting the buffer: this is the file the
/// rest of the release reads, and a rewrite that produced something its grammar
/// rejects must be found now.
fn readBack(ctx: *Ctx, path: []const u8) ![:0]u8 {
return Io.Dir.cwd().readFileAllocOptions(
ctx.io,
path,
ctx.arena,
.limited(max_input_bytes),
.of(u8),
0,
) catch |err| {
ctx.soft("bump", "cannot read {s} back: {t}", .{ path, err });
return CheckFailed;
};
}
/// Writes the new version into `build.zig.zon` and proves the file still parses.
///
@@ -2959,41 +3041,9 @@ fn writeZonVersion(ctx: *Ctx, version: []const u8, source: [:0]const u8) !void {
ctx.soft("bump", "cannot rewrite the .version field of build.zig.zon: {t}", .{err});
return CheckFailed;
};
try replaceFile(ctx, "build.zig.zon", rewritten);
// Atomic: a cut interrupted mid-write must not leave a half-written
// manifest that neither zig nor a re-run can parse.
var atomic = Io.Dir.cwd().createFileAtomic(ctx.io, "build.zig.zon", .{ .replace = true }) catch |err| {
ctx.soft("bump", "cannot open build.zig.zon for an atomic replace: {t}", .{err});
return CheckFailed;
};
defer atomic.deinit(ctx.io);
atomic.file.writeStreamingAll(ctx.io, rewritten) catch |err| {
ctx.soft("bump", "cannot write build.zig.zon: {t}", .{err});
return CheckFailed;
};
atomic.file.sync(ctx.io) catch |err| {
ctx.soft("bump", "cannot flush build.zig.zon to disk: {t}", .{err});
return CheckFailed;
};
atomic.replace(ctx.io) catch |err| {
ctx.soft("bump", "cannot replace build.zig.zon: {t}", .{err});
return CheckFailed;
};
// Read back from disk rather than trusting the buffer: this is the file the
// rest of the release reads, and a rewrite that produced something the zon
// grammar rejects must be found now.
const written = Io.Dir.cwd().readFileAllocOptions(
ctx.io,
"build.zig.zon",
ctx.arena,
.limited(max_input_bytes),
.of(u8),
0,
) catch |err| {
ctx.soft("bump", "cannot read build.zig.zon back: {t}", .{err});
return CheckFailed;
};
const written = try readBack(ctx, "build.zig.zon");
const reparsed = parseZonVersion(ctx.arena, written) catch |err| {
ctx.soft("bump", "the rewritten build.zig.zon does not parse: {t}", .{err});
return CheckFailed;
@@ -3005,8 +3055,39 @@ fn writeZonVersion(ctx: *Ctx, version: []const u8, source: [:0]const u8) !void {
ctx.pass("bump", "build.zig.zon declares {s}", .{version});
}
/// Commits the manifest bump and the pins as one commit, after asserting the
/// working tree holds those two files and nothing else.
/// The compose file with its image default moved to `version`, computed before
/// anything is written: a compose file the rewrite cannot read is a refusal
/// while the tree is still clean.
fn composeWithVersion(ctx: *Ctx, version: []const u8) ![]u8 {
const source = Io.Dir.cwd().readFileAlloc(ctx.io, compose_path, ctx.arena, .limited(max_input_bytes)) catch |err| {
ctx.soft("bump", "cannot read {s}: {t}", .{ compose_path, err });
return CheckFailed;
};
return rewriteComposeVersion(ctx.arena, source, version) catch |err| {
ctx.soft("bump", "cannot rewrite the image default of {s}: {t}", .{ compose_path, err });
return CheckFailed;
};
}
fn writeComposeVersion(ctx: *Ctx, version: []const u8, rewritten: []const u8) !void {
try replaceFile(ctx, compose_path, rewritten);
const written = try readBack(ctx, compose_path);
const at = findComposeVersion(written) catch |err| {
ctx.soft("bump", "the rewritten {s} has no single image default: {t}", .{ compose_path, err });
return CheckFailed;
};
if (!std.mem.eql(u8, written[at.start..at.end], version)) {
ctx.soft("bump", "the rewritten {s} defaults to '{s}', expected '{s}'", .{
compose_path, written[at.start..at.end], version,
});
return CheckFailed;
}
ctx.pass("bump", "{s} defaults to {s}", .{ compose_path, version });
}
/// Commits the manifest bump, the compose default and the pins as one commit,
/// after asserting the working tree holds those files and nothing else.
///
/// `bump_needed` is false on a resumed cut: the commit already exists, so the
/// pin stage has just recomputed pins that are already in it. An empty diff is
@@ -3036,8 +3117,8 @@ fn commitBump(ctx: *Ctx, version: []const u8, bump_needed: bool) !void {
for (bump_paths) |allowed| {
if (std.mem.eql(u8, path, allowed)) break;
} else {
ctx.soft("bump", "the cut changed '{s}'; the bump commit is {s} and {s} and nothing else", .{
path, bump_paths[0], bump_paths[1],
ctx.soft("bump", "the cut changed '{s}'; the bump commit is {s}, {s} and {s} and nothing else", .{
path, bump_paths[0], bump_paths[1], bump_paths[2],
});
return CheckFailed;
}
@@ -3067,9 +3148,10 @@ fn commitBump(ctx: *Ctx, version: []const u8, bump_needed: bool) !void {
"-S", "-m",
ctx.fmt("build: bump version to {s}", .{version}), "--",
bump_paths[0], bump_paths[1],
bump_paths[2],
});
ctx.pass("bump", "one commit: {s} declares {s} and {s} pins its release hashes", .{
bump_paths[0], version, bump_paths[1],
ctx.pass("bump", "one commit: {s} declares {s}, {s} defaults to it and {s} pins its release hashes", .{
bump_paths[0], version, bump_paths[2], bump_paths[1],
});
}
@@ -3698,6 +3780,56 @@ test "the zon version survives a rewrite round trip" {
try testing.expectError(error.NoVersionField, findVersionValue("// .version = \"9.9.9\""));
}
test "the compose image default survives a rewrite round trip" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const source =
\\services:
\\ nxdns:
\\ # NXDNS_VERSION=0.0.1 docker compose up -d runs another release.
\\ image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-0.0.7}}
\\ restart: unless-stopped
;
const at = try findComposeVersion(source);
try testing.expectEqualStrings("0.0.7", source[at.start..at.end]);
const rewritten = try rewriteComposeVersion(arena, source, "0.0.8");
const moved = try findComposeVersion(rewritten);
try testing.expectEqualStrings("0.0.8", rewritten[moved.start..moved.end]);
// Only the default moved: the comment naming another version and the
// outer `NXDNS_IMAGE` default are untouched.
try testing.expect(std.mem.indexOf(u8, rewritten, "# NXDNS_VERSION=0.0.1 docker") != null);
try testing.expect(std.mem.indexOf(u8, rewritten, "${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:") != null);
try testing.expectEqual(source.len, rewritten.len);
const image = " image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-";
try testing.expectError(error.NoComposeVersion, findComposeVersion(" image: nxdns:latest"));
try testing.expectError(error.NoComposeVersion, findComposeVersion(image ++ "}}"));
try testing.expectError(error.NoComposeVersion, findComposeVersion(image ++ "0.0.7}"));
try testing.expectError(error.NoComposeVersion, findComposeVersion(image ++ "${OTHER}}}"));
// The text in a comment, or after any other key, is not the image.
try testing.expectError(error.NoComposeVersion, findComposeVersion(" # " ++ image[4..] ++ "0.0.7}}"));
try testing.expectError(error.NoComposeVersion, findComposeVersion(" x-image: ${NXDNS_VERSION:-0.0.7}"));
try testing.expectError(error.ManyComposeVersions, findComposeVersion(image ++ "0.0.7}}\n" ++ image ++ "0.0.7}}"));
}
test "this repository's compose file defaults to the version build.zig.zon declares" {
// The bump commit moves both together; this catches a hand edit of either
// between two cuts, on every CI run rather than at the next release.
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const zon = try Io.Dir.cwd().readFileAllocOptions(threaded.io(), "build.zig.zon", arena, .limited(max_input_bytes), .of(u8), 0);
const compose = try Io.Dir.cwd().readFileAlloc(threaded.io(), compose_path, arena, .limited(max_input_bytes));
const at = try findComposeVersion(compose);
try testing.expectEqualStrings(try parseZonVersion(arena, zon), compose[at.start..at.end]);
}
test "the changelog section must exist, be dated and say something" {
const good =
\\# Changelog