From aa8c9e28af9716f61e11eafc20dd9cbebdbb38b8 Mon Sep 17 00:00:00 2001 From: m5r Date: Tue, 20 Oct 2026 11:46:13 +0200 Subject: [PATCH] deploy: the compose file names its own release, and the cut moves it with the version --- CHANGELOG.md | 1 + README.md | 4 +- deploy/docker/compose.yaml | 11 +- docs/how-to/install-with-docker.md | 14 +- docs/how-to/upgrade.md | 34 +++-- specs/release-cut.md | 10 ++ tools/cut.zig | 228 +++++++++++++++++++++++------ 7 files changed, 231 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77ebabf..afb8d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Sections are written by hand. Nothing here is generated from commit messages: th ### Changed +- **The compose file names its own release.** `deploy/docker/compose.yaml` defaults its image to the release it is tagged with instead of `:latest`, so `docker compose up -d` with the file from a tag runs that tag's image, and the install and upgrade steps no longer set `NXDNS_VERSION`. The variable still picks another release, and `NXDNS_IMAGE` still names a local image. Compose files from `v0.0.22` and earlier keep the `:latest` default. - **A DoH upstream that answers with an error status says which.** The Diagnostics episode and `/api/health` read `HttpStatus (status 505 HTTP Version Not Supported)` instead of `HttpStatus (cause HttpStatus)`. A 505 is a server that requires HTTP/2, which nxdns does not speak. ## [0.0.22] - 2026-10-19 diff --git a/README.md b/README.md index 7d2255c..6d08a0e 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,10 @@ cat > etc-nxdns/config.zon <<'EOF' .web = .{ .password = "choose-a-real-password" }, } EOF -NXDNS_VERSION= docker compose up -d +docker compose up -d ``` -The compose file defaults to `:latest`; pin a version for anything you intend to keep running. To run an image you built yourself, name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md) describes. DNS is on port 53, the web UI on . +The compose file names the release it belongs to, so a checkout of a release tag runs that release's image. `NXDNS_VERSION= docker compose up -d` runs another release. To run an image you built yourself, name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md) describes. DNS is on port 53, the web UI on . The compose file runs `nxdns run --config=/etc/nxdns/config.zon`, which makes that file the configuration: every start reconciles the database onto it, and the UI refuses configuration edits. Edit the file and restart to change anything. Drop the `command:` line to run bare `nxdns run` instead, where the database is the configuration and changes go through the UI, the API, or `nxdns export` / `nxdns import` — the packaged systemd unit does that. Which mode is live is printed at every start (`authority: database` / `authority: file ()`); see [docs/explanation/configuration-model.md](docs/explanation/configuration-model.md). diff --git a/deploy/docker/compose.yaml b/deploy/docker/compose.yaml index 502a936..d8d2dbc 100644 --- a/deploy/docker/compose.yaml +++ b/deploy/docker/compose.yaml @@ -1,10 +1,11 @@ services: nxdns: - # Published multi-architecture image. Pin an exact version in production: - # NXDNS_VERSION=0.0.1 docker compose up -d. To run a locally built image - # instead, build it first (see deploy/docker/Dockerfile) and set - # NXDNS_IMAGE=nxdns. - image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}} + # Published multi-architecture image. The default is the release this file + # is tagged with: `zig build cut` moves it in the bump commit, so the file + # from a tag runs that tag's image. NXDNS_VERSION picks another release. To + # run a locally built image instead, build it first (see + # deploy/docker/Dockerfile) and set NXDNS_IMAGE=nxdns. + image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-0.0.22}} restart: unless-stopped # `run --config` makes the file the sole source of configuration: nxdns # reconciles the database onto ./etc-nxdns/config.zon at every start, and diff --git a/docs/how-to/install-with-docker.md b/docs/how-to/install-with-docker.md index 34391b0..4cb079d 100644 --- a/docs/how-to/install-with-docker.md +++ b/docs/how-to/install-with-docker.md @@ -132,17 +132,17 @@ chmod 0600 deploy/docker/etc-nxdns/config.zon ## 3. Run it ```sh -NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml up -d +docker compose -f deploy/docker/compose.yaml up -d docker compose -f deploy/docker/compose.yaml logs -f ``` -`compose.yaml` reads the image from two variables: `${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}}`. Set `NXDNS_VERSION` to pin a release; set `NXDNS_IMAGE` to run something else entirely, which is what the build-from-source section at the bottom does. +`compose.yaml` names the release it was tagged with: its image is `${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-}}`, and `zig build cut` writes `` in the same commit that bumps `build.zig.zon`, so the file from the `v$VERSION` tag runs `$VERSION` with no variable set. Set `NXDNS_VERSION` to run a different release with the same file; set `NXDNS_IMAGE` to run something else entirely, which is what the build-from-source section at the bottom does. Compose files from `v0.0.22` and earlier default to `:latest` instead, so with one of those, set `NXDNS_VERSION=$VERSION` on every command. -> Verified on this host with `docker compose -f deploy/docker/compose.yaml -> config`, which resolves the variables without contacting a registry: no -> variables gives `git.mial.net/mokhtar/nxdns:latest`, `NXDNS_VERSION=0.0.1` -> gives `git.mial.net/mokhtar/nxdns:0.0.1`, and `NXDNS_IMAGE=nxdns` gives -> `nxdns`. +> Verified on 2026-10-20 with `docker compose -f deploy/docker/compose.yaml +> config` on a tree whose `build.zig.zon` declares `0.0.22`, which resolves +> the variables without contacting a registry: no variables gives +> `git.mial.net/mokhtar/nxdns:0.0.22`, `NXDNS_VERSION=0.0.21` gives +> `git.mial.net/mokhtar/nxdns:0.0.21`, and `NXDNS_IMAGE=nxdns` gives `nxdns`. Every block on this page runs from the repository root, and none of them change directory, so they can be pasted in order. `-f` is what makes that work: Compose resolves the relative paths inside `compose.yaml` — the `etc-nxdns` bind mount — against the directory holding the file, not against your shell, and it takes the project name `docker` from that directory either way, which is why the container is `docker-nxdns-1`. diff --git a/docs/how-to/upgrade.md b/docs/how-to/upgrade.md index 9020fc0..2d07463 100644 --- a/docs/how-to/upgrade.md +++ b/docs/how-to/upgrade.md @@ -168,20 +168,36 @@ journalctl -u nxdns -f ### Docker +The compose file names the release it belongs to, so upgrading the image is upgrading the file. A new release's compose file can change more than the image — the `command:` line or a port — so both ways below replace yours: if you edited it by hand, carry your edits over to the new one. To keep your own file as it is, skip the `curl` or the `git checkout` and set `NXDNS_VERSION=$VERSION` on the `pull` and `up` lines instead: it overrides the release the file names. A compose file from `v0.0.22` or earlier defaults to `:latest`, so with one of those the variable is needed on both lines, or `pull` and `up` can land on different images if a release happens between them. + +With the single downloaded file of [Install with Docker](install-with-docker.md), fetch the new release's copy over it in its directory, then pull and recreate: + ```sh -NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml pull -NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml up -d +cd ~/nxdns +curl -fLO "$BASE/raw/tag/v$VERSION/deploy/docker/compose.yaml" +docker compose pull +docker compose up -d +``` + +With a checkout of the repository, fetch the tag and move to it, from the repository root: + +```sh +git fetch origin tag "v$VERSION" +git checkout "v$VERSION" +docker compose -f deploy/docker/compose.yaml pull +docker compose -f deploy/docker/compose.yaml up -d ``` Compose recreates the container against the same `nxdns-data` volume. What happens to the file in `etc-nxdns` depends on the `command:` in your compose file: with the shipped `run --config=/etc/nxdns/config.zon` the file is the configuration and the restart reconciles onto it; without it, the database in the volume is the configuration and the file is read by nothing. -Set `NXDNS_VERSION` on both lines, or export it. Without it the compose file falls back to `:latest`, and `pull` and `up` could then land on different images if a release happens between them. - -> Not run on this host: `pull` needs a published image, and there is none. -> What was run is `docker compose -f deploy/docker/compose.yaml config`, which -> resolves the variables without contacting a registry: `NXDNS_VERSION=0.0.1` -> gave `image: git.mial.net/mokhtar/nxdns:0.0.1`, and no variable at all gave -> `:latest`. +> Not run on this host: `pull` and `up` were not run against a new release. +> What was run on 2026-10-20 is `docker compose -f deploy/docker/compose.yaml +> config` on a tree whose `build.zig.zon` declares `0.0.22`, which resolves +> the variables without contacting a registry: no variable gave +> `image: git.mial.net/mokhtar/nxdns:0.0.22`, and `NXDNS_VERSION=0.0.21` gave +> `image: git.mial.net/mokhtar/nxdns:0.0.21`. +> The fetch half was run the same day against `v0.0.22`: the `curl` URL +> returned 200, and `git fetch --dry-run origin tag v0.0.22` exited 0. ## 4. Confirm the upgrade diff --git a/specs/release-cut.md b/specs/release-cut.md index 3d7ea3b..027d5dd 100644 --- a/specs/release-cut.md +++ b/specs/release-cut.md @@ -102,3 +102,13 @@ On a resumed cut — the bump commit exists and its tag does not — the pin sta ### Tests The `gates.yml` env parser against a fixture and against the real file; the environment builder, asserting the nine keys and nothing else; and the `sh` wrapper, asserting it applies the umask, keeps the real command as the direct child, and reports its exit code. The stage's remaining steps are process spawns of `npm`, `zig` and `nix`, which are not mocked — the same rule the rest of this file follows. + +## Addendum: the compose file names its release (post-0.0.22) + +Owner question (2026-10-20), on the site's `NXDNS_VERSION=0.0.22 docker compose up -d`: "why do we need the NXDNS_VERSION to pull up a docker compose file? shouldnt the version be specified in the file already??" Until 0.0.22, `deploy/docker/compose.yaml` read `${NXDNS_VERSION:-latest}`, so the file fetched from a tag ran whatever `:latest` was that day, and every install and upgrade command had to carry the variable. + +The image default is now the release itself: `${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-}}`. `NXDNS_VERSION` and `NXDNS_IMAGE` stay as overrides. + +- **The bump moves it.** This amends step 2 of the pin stage: the cut reads the compose file and computes its rewrite before anything is written, so a file without exactly one `${NXDNS_VERSION:-…}` default is a refusal on a clean tree. Then `build.zig.zon` and the compose file are both written atomically and read back. Step 5's commit takes `build.zig.zon`, `flake.nix` and `deploy/docker/compose.yaml`, and the working-tree diff is asserted to be a subset of those three paths. +- **One guard, on every commit.** A cut-tool test reads the real `build.zig.zon` and `deploy/docker/compose.yaml` and asserts that the default equals the declared version. It runs in `zig build test`, so a hand edit of either file fails CI on that commit. The tag's tree is the bump commit's tree, which passed CI before the tag was made, so `verify-dist` gets no second copy of the check. +- **The docs drop the variable from the plain flow.** README, install-with-docker and upgrade run `docker compose up -d`; the variables are documented as overrides, and the pages say that a compose file from `v0.0.22` or earlier still defaults to `:latest`. diff --git a/tools/cut.zig b/tools/cut.zig index 16a02e5..f636d01 100644 --- a/tools/cut.zig +++ b/tools/cut.zig @@ -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 `` in the image line +/// `image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_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