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
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:
+180
-48
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user