cut: schema gate — refuse to release an undisclosed querylog schema change

the gate recomputes the previous release tag's ddl fingerprint from the
remote peeled object and compares it against the tree's; a change must
be disclosed by 'resets your query history' in the version's changelog
section. the 0.0.9 reset shipped with an announcement claiming no
schema change; this makes the impact mechanical instead of remembered.
This commit is contained in:
2026-08-23 15:07:27 +02:00
parent a8fd9fee48
commit fe71efe335
5 changed files with 461 additions and 18 deletions
+6
View File
@@ -4,6 +4,12 @@ All notable changes to nxdns are recorded here. The format follows [Keep a Chang
Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does. Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does.
## [Unreleased]
### Changed
- **The release cut refuses to ship an undisclosed query-log schema change.** `zig build cut` now compares the `querylog.db` schema fingerprint of the previous release tag against this tree's, and when they differ it requires the changelog section for the version being cut to state that the upgrade discards the stored query history. 0.0.9 changed the schema and its announcement did not mention it; the file is never migrated, so that upgrade silently threw every logged query away.
## [0.0.9] - 2026-08-22 ## [0.0.9] - 2026-08-22
Query provenance: every logged query becomes exactly explainable — what the policy decided, what matched, where the answer came from and what the client saw. The handler records all of it as the reply goes out, `query_log` stores it, and a detail page reads one query back in the order the pipeline decided it. Read the upgrade note below first: it resets your query history. Query provenance: every logged query becomes exactly explainable — what the policy decided, what matched, where the answer came from and what the client saw. The handler records all of it as the reply goes out, `query_log` stores it, and a detail page reads one query back in the order the pipeline decided it. Read the upgrade note below first: it resets your query history.
+20 -1
View File
@@ -275,7 +275,21 @@ pub fn build(b: *std.Build) void {
// needs the operator's terminal so `git commit -S` can reach pinentry — // needs the operator's terminal so `git commit -S` can reach pinentry —
// none of which a workflow supplies and all of which a Run step passes // none of which a workflow supplies and all of which a Run step passes
// through. // through.
// The cut's schema gate compares the querylog fingerprint of the previous
// release against this tree's. It must read that number from the file the
// server uses, never from a copy: a duplicated DDL or a duplicated hash
// would let the gate pass a schema change it no longer describes. Only
// `fingerprint` and `fingerprintOf` are referenced, both of which are
// comptime-computable text hashing, so no SQLite symbol is pulled in and
// the host tool needs no library.
const querylog_schema_mod = b.createModule(.{
.root_source_file = b.path("src/storage/querylog_schema.zig"),
.target = b.graph.host,
.optimize = optimize,
});
const cut_tool = hostTool(b, "cut"); const cut_tool = hostTool(b, "cut");
cut_tool.root_module.addImport("querylog_schema", querylog_schema_mod);
const cut_run = b.addRunArtifact(cut_tool); const cut_run = b.addRunArtifact(cut_tool);
// It pushes commits and tags, so it must never be answered from the run // It pushes commits and tags, so it must never be answered from the run
// cache, and it must run at the build root whatever directory `zig build` // cache, and it must run at the build root whatever directory `zig build`
@@ -298,7 +312,12 @@ pub fn build(b: *std.Build) void {
.optimize = optimize, .optimize = optimize,
}), }),
}); });
test_step.dependOn(&b.addRunArtifact(cut_tests).step); cut_tests.root_module.addImport("querylog_schema", querylog_schema_mod);
const cut_tests_run = b.addRunArtifact(cut_tests);
// The schema-gate round trip reads `src/storage/querylog_schema.zig` off
// disk, so the test binary has to run at the build root.
cut_tests_run.setCwd(b.path("."));
test_step.dependOn(&cut_tests_run.step);
addDist(b, options, admin_assets, .{ addDist(b, options, admin_assets, .{
.version = version_option, .version = version_option,
+15
View File
@@ -54,3 +54,18 @@ Pure functions unit-tested: semver validation (accept/reject table incl. leading
- [ ] `just --list` shows the recipes; `just verify` passes locally. - [ ] `just --list` shows the recipes; `just verify` passes locally.
- [ ] `zig build cut -- patch` derives the next version and refuses in preflight on a dirty tree or a missing changelog section, mutating nothing; `zig build cut -- 0.0.9` and `-- banana` refuse naming the three kinds. - [ ] `zig build cut -- patch` derives the next version and refuses in preflight on a dirty tree or a missing changelog section, mutating nothing; `zig build cut -- 0.0.9` and `-- banana` refuse naming the three kinds.
- [ ] `zig build test` and `-Dintegration` 0 failed; `zig fmt --check` clean. - [ ] `zig build test` and `-Dintegration` 0 failed; `zig fmt --check` clean.
## Addendum: the schema gate (post-0.0.9)
0.0.9 changed the `query_log` DDL and its announcement said nothing about it. `querylog.db` is never migrated: the server stamps `PRAGMA user_version` with a CRC32 of the DDL text, and on a mismatch it renames the file aside and creates an empty one, so the first start after such a release destroys the operator's query history. Nothing in the cut noticed, because nothing in the cut had ever read the schema.
`schema-gate` is a read-only preflight check beside the others. It compares releases, not commits:
1. `git ls-remote --tags origin`, and the highest `vMAJOR.MINOR.PATCH` strictly below the version being cut is the previous release. Strictly below, because a rerun may already see the tag it is cutting. What is kept is the OBJECT ID origin published for that tag — the peeled `^{}` commit where there is one — not the tag name: a local tag of the same name can be stale or replaced, and reading its tree would compare against a schema origin never shipped, which passes silently whenever that schema happens to match this one. No such tag PASSES trivially — a first release has nothing to compare against.
2. `git show <oid>:src/storage/querylog_schema.zig`, and `extractDdl` recovers the `ddl` constant from that source the way the compiler reads a multiline string: the lines after `pub const ddl: [:0]const u8 =` that begin with `\\`, stripped of indentation and the `\\`, joined with newlines, ending at the `;`. Blank lines and `//` comments may appear before, between and after the `\\` lines and contribute nothing, exactly as the compiler treats them. A test applies the same function to the file on disk and asserts the result fingerprints to `querylog_schema.fingerprint` — that equality is what makes the text scan trustworthy.
3. The old DDL goes through `querylog_schema.fingerprintOf`, factored out of the comptime `fingerprint` so the gate and the server share one hash rather than two copies of one expression. The tool imports the schema module (build.zig, `querylog_schema_mod`); only these two decls are referenced, so no SQLite symbol comes with them.
4. Equal fingerprints PASS. Different fingerprints require the `## [<v>]` changelog section to contain the literal phrase `resets your query history`; present PASSES, absent is a soft FAIL naming both fingerprints, the phrase and what the change costs.
Every step that cannot answer — the `ls-remote`, the `git show`, the extraction, an unreadable CHANGELOG.md — is a soft FAIL naming the step. A gate that does not know whether the schema moved must never report that it did not.
Fixing a FAIL is a sentence in the changelog, not a flag: there is no override, because the only thing the gate asks for is that the release notes be true.
+13 -1
View File
@@ -80,6 +80,14 @@ pub const ddl: [:0]const u8 =
\\VALUES (1, unixepoch(), unixepoch() + 1); \\VALUES (1, unixepoch(), unixepoch() + 1);
; ;
/// The fingerprint of an arbitrary DDL text. `tools/cut.zig` calls this at
/// runtime on the DDL of the previous release tag, so the release gate and the
/// server compute the same number from the same function rather than from two
/// copies of one expression.
pub fn fingerprintOf(text: []const u8) i32 {
return @bitCast(std.hash.Crc32.hash(text));
}
/// `PRAGMA user_version` is a signed 32-bit field. Deriving the fingerprint from /// `PRAGMA user_version` is a signed 32-bit field. Deriving the fingerprint from
/// the DDL means editing the schema automatically invalidates every existing /// the DDL means editing the schema automatically invalidates every existing
/// file — which is exactly the policy. /// file — which is exactly the policy.
@@ -87,7 +95,7 @@ pub const fingerprint: i32 = blk: {
// Covers the CRC lookup-table generation in std.hash.crc, which evaluates // Covers the CRC lookup-table generation in std.hash.crc, which evaluates
// under this scope's quota and overflows the 1000 default (and 100k). // under this scope's quota and overflows the 1000 default (and 100k).
@setEvalBranchQuota(2_000_000); @setEvalBranchQuota(2_000_000);
break :blk @bitCast(std.hash.Crc32.hash(ddl)); break :blk fingerprintOf(ddl);
}; };
const set_user_version = std.fmt.comptimePrint("PRAGMA user_version = {d};", .{fingerprint}); const set_user_version = std.fmt.comptimePrint("PRAGMA user_version = {d};", .{fingerprint});
@@ -280,6 +288,10 @@ const testing = std.testing;
test "fingerprint matches a fresh hash of the DDL" { test "fingerprint matches a fresh hash of the DDL" {
try testing.expectEqual(fingerprint, @as(i32, @bitCast(std.hash.Crc32.hash(ddl)))); try testing.expectEqual(fingerprint, @as(i32, @bitCast(std.hash.Crc32.hash(ddl))));
// The runtime entry point the release gate uses is the same function the
// comptime constant is built from.
try testing.expectEqual(fingerprint, fingerprintOf(ddl));
try testing.expect(fingerprintOf(ddl[0 .. ddl.len - 1]) != fingerprint);
} }
test "ddl creates the query-log tables and every index" { test "ddl creates the query-log tables and every index" {
+407 -16
View File
@@ -52,6 +52,11 @@ const Allocator = std.mem.Allocator;
const Io = std.Io; const Io = std.Io;
const http = std.http; const http = std.http;
/// Imported for two decls only — `fingerprint` and `fingerprintOf` — so the
/// gate below reads the number the server will compute rather than a copy of
/// the expression that computes it.
const querylog_schema = @import("querylog_schema");
const max_input_bytes = 1 << 30; const max_input_bytes = 1 << 30;
/// The only repository this program can ever act on. There is no flag for it: /// The only repository this program can ever act on. There is no flag for it:
@@ -493,28 +498,172 @@ const ChangelogCheck = enum {
/// today: a section written the evening before a morning cut is correct, and a /// today: a section written the evening before a morning cut is correct, and a
/// tool that demanded today would make the operator lie in the file. /// tool that demanded today would make the operator lie in the file.
fn checkChangelog(source: []const u8, version: []const u8) ChangelogCheck { fn checkChangelog(source: []const u8, version: []const u8) ChangelogCheck {
var state: ChangelogCheck = .missing; const heading = changelogHeadingRest(source, version) orelse return .missing;
var in_section = false; if (!isDateSuffix(heading)) return .undated;
var body_seen = false;
const body = changelogSection(source, version) orelse return .missing;
var lines = std.mem.splitScalar(u8, body, '\n');
while (lines.next()) |line| {
if (!isBlank(line)) return .ok;
}
return .empty;
}
/// The `## [<version>]` heading's trailing part, from the FIRST such heading.
/// A file with two headings for one version is a file whose first section is
/// the one every reader — this program, `release.zig` and a human — takes.
fn changelogHeadingRest(source: []const u8, version: []const u8) ?[]const u8 {
var lines = std.mem.splitScalar(u8, source, '\n'); var lines = std.mem.splitScalar(u8, source, '\n');
while (lines.next()) |raw| { while (lines.next()) |raw| {
const line = std.mem.trimEnd(u8, raw, "\r"); const line = std.mem.trimEnd(u8, raw, "\r");
if (versionHeadingRest(line, version)) |rest| return rest;
}
return null;
}
if (in_section) { /// Everything below the `## [<version>]` heading and above whatever ends the
if (std.mem.startsWith(u8, line, "## ") or isLinkReference(line)) break; /// section: the next `## ` heading, or the Keep a Changelog link-reference
if (!isBlank(line)) body_seen = true; /// block at the foot of the file. Null when there is no such heading.
///
/// `checkChangelog` reads it for emptiness and the schema gate reads it for one
/// disclosure phrase. Both must be looking at the same bytes, which is why
/// there is one extractor and not two loops.
fn changelogSection(source: []const u8, version: []const u8) ?[]const u8 {
var offset: usize = 0;
var start: ?usize = null;
var lines = std.mem.splitScalar(u8, source, '\n');
while (lines.next()) |raw| {
const line_start = offset;
offset += raw.len + 1;
const line = std.mem.trimEnd(u8, raw, "\r");
if (start) |from| {
if (std.mem.startsWith(u8, line, "## ") or isLinkReference(line)) {
return source[from..line_start];
}
continue;
}
if (versionHeadingRest(line, version) != null) start = @min(offset, source.len);
}
const from = start orelse return null;
return source[@min(from, source.len)..];
}
/// The phrase a changelog section must carry to release a querylog schema
/// change. It is the operator-facing consequence, not the mechanism: what a
/// reader of the release notes needs to know is that upgrading throws their
/// query history away.
const history_reset_phrase = "resets your query history";
fn disclosesHistoryReset(section: []const u8) bool {
return std.mem.indexOf(u8, section, history_reset_phrase) != null;
}
/// The file whose DDL decides whether `querylog.db` survives an upgrade.
const querylog_schema_path = "src/storage/querylog_schema.zig";
/// The declaration line the DDL follows, matched whole so no other `ddl` in the
/// file can be mistaken for it.
const ddl_declaration = "pub const ddl: [:0]const u8 =";
/// The bytes of the `ddl` constant, recovered from the SOURCE of any revision of
/// `querylog_schema.zig`.
///
/// The old release's DDL only exists as text — `git show <tag>:<path>` — so the
/// gate has to read a Zig multiline string the way the compiler does: every
/// line after the declaration begins with optional indentation and `\\`, each
/// carries the rest of the line verbatim, and the lines join with a newline
/// between them and none after the last. The terminating `;` ends the literal.
///
/// Null when the declaration, the literal or the terminator is not where this
/// expects it. That is a refusal, never an empty DDL: an empty string has a
/// perfectly good fingerprint that would compare unequal and turn a
/// parse failure into a false schema change — or, worse, equal by accident.
fn extractDdl(arena: Allocator, file_text: []const u8) ?[]const u8 {
var parts: std.ArrayList([]const u8) = .empty;
var found_declaration = false;
var lines = std.mem.splitScalar(u8, file_text, '\n');
while (lines.next()) |raw| {
const line = std.mem.trimEnd(u8, raw, "\r");
if (!found_declaration) {
if (std.mem.eql(u8, std.mem.trim(u8, line, " \t"), ddl_declaration)) found_declaration = true;
continue; continue;
} }
const rest = versionHeadingRest(line, version) orelse continue; const body = std.mem.trimStart(u8, line, " \t");
state = if (isDateSuffix(rest)) .ok else .undated; if (std.mem.startsWith(u8, body, "\\\\")) {
if (state == .undated) return .undated; parts.append(arena, body["\\\\".len..]) catch @panic("OOM");
in_section = true; continue;
}
// Zig allows blank lines and `//` comments before, between and after the
// `\\` lines of one literal, and none of them contribute a byte to the
// compiled string. Treating them as a parse failure would wedge every
// cut from the moment such a source shipped in a tag.
if (isBlank(body) or std.mem.startsWith(u8, body, "//")) continue;
if (std.mem.eql(u8, std.mem.trimEnd(u8, body, " \t"), ";")) {
if (parts.items.len == 0) return null;
return std.mem.join(arena, "\n", parts.items) catch @panic("OOM");
}
return null;
}
return null;
} }
if (state != .ok) return state; /// A release tag as origin reports it: the version, and the object id to read
return if (body_seen) .ok else .empty; /// the old source out of.
const PreviousRelease = struct {
version: Semver,
/// The id ORIGIN published for that tag, never the local ref of the same
/// name. A local tag can be stale or have been replaced, and reading its
/// tree would compare this release against a schema origin never shipped —
/// which, if that schema happened to match this one, is a silent pass.
object: []const u8,
/// Whether `object` came from the peeled `refs/tags/v…^{}` line. The peeled
/// line is the commit an annotated tag points at, which is what `git show
/// <id>:<path>` needs; the unpeeled id of an annotated tag is the tag
/// object, and `git show` on that resolves to the same commit, so either
/// works and the peeled one is preferred as the direct answer.
peeled: bool,
};
/// The highest `vMAJOR.MINOR.PATCH` tag in `git ls-remote --tags` output that is
/// strictly below `target`, or null when there is none.
///
/// Strictly below, because the tag being cut may already be listed on a rerun,
/// and a range that ended at the version being cut would compare the tree
/// against itself and pass every time.
fn previousReleaseTag(ls_remote_stdout: []const u8, target: Semver) ?PreviousRelease {
var best: ?PreviousRelease = null;
var lines = std.mem.splitScalar(u8, ls_remote_stdout, '\n');
while (lines.next()) |raw| {
const line = std.mem.trimEnd(u8, raw, " \t\r");
const tab = std.mem.indexOfScalar(u8, line, '\t') orelse continue;
const object = std.mem.trim(u8, line[0..tab], " \t");
if (object.len == 0) continue;
const name = std.mem.trim(u8, line[tab + 1 ..], " \t");
const peeled = std.mem.endsWith(u8, name, "^{}");
const bare = if (peeled) name[0 .. name.len - 3] else name;
if (!std.mem.startsWith(u8, bare, "refs/tags/v")) continue;
const found = parseSemver(bare["refs/tags/v".len..]) orelse continue;
if (!semverLess(found, target)) continue;
if (best) |current| {
if (semverLess(found, current.version)) continue;
// The same tag appears twice, unpeeled and peeled, in either order.
if (!semverLess(current.version, found) and (current.peeled or !peeled)) continue;
}
best = .{ .version = found, .object = object, .peeled = peeled };
}
return best;
}
fn semverLess(a: Semver, b: Semver) bool {
if (a.major != b.major) return a.major < b.major;
if (a.minor != b.minor) return a.minor < b.minor;
return a.patch < b.patch;
} }
/// The part of a `## [<version>]…` heading after the closing bracket, or null /// The part of a `## [<version>]…` heading after the closing bracket, or null
@@ -1342,17 +1491,21 @@ fn preflight(ctx: *Ctx, version: []const u8, bump_needed: bool, plan: Plan) !Pre
ctx.pass("branch", "master", .{}); ctx.pass("branch", "master", .{});
} }
if (Io.Dir.cwd().readFileAlloc(ctx.io, "CHANGELOG.md", ctx.arena, .limited(max_input_bytes))) |changelog| { const changelog: ?[]const u8 = if (Io.Dir.cwd().readFileAlloc(ctx.io, "CHANGELOG.md", ctx.arena, .limited(max_input_bytes))) |source| source else |err| blk: {
switch (checkChangelog(changelog, version)) { ctx.soft("changelog", "cannot read CHANGELOG.md: {t}", .{err});
break :blk null;
};
if (changelog) |source| {
switch (checkChangelog(source, version)) {
.ok => ctx.pass("changelog", "## [{s}] has a dated heading and a section body", .{version}), .ok => ctx.pass("changelog", "## [{s}] has a dated heading and a section body", .{version}),
.missing => ctx.soft("changelog", "CHANGELOG.md has no `## [{s}] - YYYY-MM-DD` heading", .{version}), .missing => ctx.soft("changelog", "CHANGELOG.md has no `## [{s}] - YYYY-MM-DD` heading", .{version}),
.undated => ctx.soft("changelog", "the `## [{s}]` heading carries no ` - YYYY-MM-DD` date", .{version}), .undated => ctx.soft("changelog", "the `## [{s}]` heading carries no ` - YYYY-MM-DD` date", .{version}),
.empty => ctx.soft("changelog", "the `## [{s}]` section is empty; the release tool refuses a blank section, and finding that out after the tag is pushed burns the tag", .{version}), .empty => ctx.soft("changelog", "the `## [{s}]` section is empty; the release tool refuses a blank section, and finding that out after the tag is pushed burns the tag", .{version}),
} }
} else |err| {
ctx.soft("changelog", "cannot read CHANGELOG.md: {t}", .{err});
} }
try schemaGate(ctx, version, plan.semver(), changelog);
const tag = ctx.fmt("v{s}", .{version}); const tag = ctx.fmt("v{s}", .{version});
const tag_ref = ctx.fmt("refs/tags/{s}", .{tag}); const tag_ref = ctx.fmt("refs/tags/{s}", .{tag});
@@ -1408,6 +1561,85 @@ fn preflight(ctx: *Ctx, version: []const u8, bump_needed: bool, plan: Plan) !Pre
return result; return result;
} }
/// Refuses a release that changes the querylog schema without saying so.
///
/// `querylog.db` is never migrated: the server compares the file's stamped
/// fingerprint against this build's and, on a mismatch, sets the file aside and
/// creates an empty one. Every query the operator ever logged is gone on the
/// first start after the upgrade. v0.0.9 shipped exactly that while its
/// announcement claimed no such change, which is what this check exists to stop.
///
/// The comparison is between the DDL of the previous release tag and this
/// tree's, so it measures the release, not the last commit. Every step that can
/// fail — listing the tags, reading the old file, parsing it — is a refusal
/// naming the step: a gate that cannot tell whether the schema moved must not
/// report that it did not.
fn schemaGate(ctx: *Ctx, version: []const u8, target: Semver, changelog: ?[]const u8) !void {
const current = querylog_schema.fingerprint;
const tags = try gitCapture(ctx, &.{ "git", "ls-remote", "--tags", "origin" }, git_network_timeout_s);
if (!tags.ok()) {
ctx.soft("schema-gate", "`git ls-remote --tags origin` exited {d}: {s}", .{
tags.code, std.mem.trimEnd(u8, tags.combined(ctx.arena), "\n"),
});
return;
}
const previous = previousReleaseTag(tags.stdout, target) orelse {
ctx.pass("schema-gate", "no release tag precedes {s}, so there is no schema to compare against", .{version});
return;
};
const previous_tag = ctx.fmt("v{d}.{d}.{d}", .{
previous.version.major, previous.version.minor, previous.version.patch,
});
// The object id origin published, not the tag name: a local tag of that
// name can be stale or replaced, and reading it would compare against a
// schema origin never shipped.
const show = try gitCapture(ctx, &.{
"git", "show", ctx.fmt("{s}:{s}", .{ previous.object, querylog_schema_path }),
}, git_local_timeout_s);
if (!show.ok()) {
ctx.soft("schema-gate", "`git show {s}:{s}` for {s} exited {d}: {s}; fetch the object with `git fetch --tags origin`", .{
previous.object, querylog_schema_path, previous_tag, show.code,
std.mem.trimEnd(u8, show.combined(ctx.arena), "\n"),
});
return;
}
const old_ddl = extractDdl(ctx.arena, show.stdout) orelse {
ctx.soft("schema-gate", "cannot find the `{s}` literal in {s}:{s} ({s})", .{
ddl_declaration, previous.object, querylog_schema_path, previous_tag,
});
return;
};
const old = querylog_schema.fingerprintOf(old_ddl);
if (old == current) {
ctx.pass("schema-gate", "the querylog schema is unchanged since {s} (fingerprint {d})", .{ previous_tag, current });
return;
}
const source = changelog orelse {
// The changelog check already reported why it could not be read; this
// reports what that costs, because the gate has no way to clear itself.
ctx.soft("schema-gate", "the querylog schema changed since {s} ({d} to {d}) and CHANGELOG.md could not be read to check the disclosure", .{
previous_tag, old, current,
});
return;
};
const section = changelogSection(source, version) orelse "";
if (!disclosesHistoryReset(section)) {
ctx.soft(
"schema-gate",
"the querylog schema changed since {s} ({d} to {d}), so the first start after this release sets querylog.db aside and creates an empty one; say so in the `## [{s}]` section, which must contain the phrase '{s}'",
.{ previous_tag, old, current, version, history_reset_phrase },
);
return;
}
ctx.pass("schema-gate", "the querylog schema changed since {s} ({d} to {d}) and the `## [{s}]` section discloses it", .{
previous_tag, old, current, version,
});
}
/// What to do about a `v<version>` tag that exists locally. /// What to do about a `v<version>` tag that exists locally.
/// ///
/// A failed cut can leave one behind: created, then the push failed. That tag is /// A failed cut can leave one behind: created, then the push failed. That tag is
@@ -1998,6 +2230,165 @@ test "the changelog section must exist, be dated and say something" {
)); ));
} }
test "the ddl literal is recovered from the source of any revision" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const source =
\\const std = @import("std");
\\
\\/// A doc comment mentioning ddl, which is not the declaration.
\\pub const ddl: [:0]const u8 =
\\ \\CREATE TABLE domains (
\\ \\ id INTEGER PRIMARY KEY
\\ \\);
\\ \\
\\ \\CREATE INDEX idx ON domains(id);
\\;
\\
\\pub const fingerprint: i32 = 0;
;
// Exactly the bytes the compiler builds: no indentation, no trailing
// newline, and the blank `\\` line is an empty line in the middle.
try testing.expectEqualStrings(
"CREATE TABLE domains (\n id INTEGER PRIMARY KEY\n);\n\nCREATE INDEX idx ON domains(id);",
extractDdl(arena, source).?,
);
// Zig allows blank lines and `//` comments before, between and after the
// `\\` lines. None of them is a byte of the compiled string, and none of
// them may stop the extraction: a tag that shipped one would wedge every
// later cut.
const with_trivia =
\\pub const ddl: [:0]const u8 =
\\ // The tables the query log is made of.
\\
\\ \\CREATE TABLE domains (
\\ \\ id INTEGER PRIMARY KEY
\\ \\);
\\
\\ // Milestone 28 added the watermark below.
\\ \\
\\ \\CREATE INDEX idx ON domains(id);
\\
\\ // Nothing follows.
\\;
;
try testing.expectEqualStrings(
"CREATE TABLE domains (\n id INTEGER PRIMARY KEY\n);\n\nCREATE INDEX idx ON domains(id);",
extractDdl(arena, with_trivia).?,
);
// Byte-for-byte what the same schema without the trivia produces.
try testing.expectEqualStrings(extractDdl(arena, source).?, extractDdl(arena, with_trivia).?);
// Every shape this must refuse rather than fingerprint an empty string.
try testing.expect(extractDdl(arena, "pub const other = 1;\n") == null);
try testing.expect(extractDdl(arena, ddl_declaration ++ "\n") == null);
try testing.expect(extractDdl(arena, ddl_declaration ++ "\n \\\\CREATE TABLE x;\n") == null);
try testing.expect(extractDdl(arena, ddl_declaration ++ "\n ;\n") == null);
try testing.expect(extractDdl(arena, ddl_declaration ++ "\n \"one line\";\n") == null);
}
test "the extracted ddl of the file on disk reproduces the compiled fingerprint" {
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();
// The whole gate rests on this: the text scan must give the same bytes the
// compiler gave the constant, or the fingerprints it compares are not the
// fingerprints the server computes. Read from disk rather than embedded,
// because reading the file is exactly what `git show` will hand it.
const source = try Io.Dir.cwd().readFileAlloc(
threaded.io(),
querylog_schema_path,
arena,
.limited(max_input_bytes),
);
const extracted = extractDdl(arena, source) orelse return error.DdlNotFound;
try testing.expectEqual(querylog_schema.fingerprint, querylog_schema.fingerprintOf(extracted));
}
test "the previous release tag is the highest one below the version being cut" {
const tags =
"aaa\trefs/tags/v0.0.7\n" ++
"bbb\trefs/tags/v0.0.7^{}\n" ++
"ccc\trefs/tags/v0.0.10\n" ++
"ddd\trefs/tags/v0.0.9\n" ++
"eee\trefs/tags/v0.1.0\n" ++
"fff\trefs/heads/master\n" ++
"ggg\trefs/tags/nightly\n";
// Decimal ordering, so 0.0.10 beats 0.0.9.
const before_minor = previousReleaseTag(tags, parseSemver("0.1.0").?).?;
try testing.expectEqual(parseSemver("0.0.10").?, before_minor.version);
try testing.expectEqualStrings("ccc", before_minor.object);
// Strictly below: the tag being cut may already be listed on a rerun, and
// comparing the tree against itself would pass every time.
const before_patch = previousReleaseTag(tags, parseSemver("0.0.10").?).?;
try testing.expectEqual(parseSemver("0.0.9").?, before_patch.version);
try testing.expectEqualStrings("ddd", before_patch.object);
try testing.expectEqual(parseSemver("0.1.0").?, previousReleaseTag(tags, parseSemver("1.0.0").?).?.version);
// The object id is what the gate reads the old source out of, so an
// annotated tag yields its PEELED commit rather than the tag object, in
// whichever order the two lines arrive.
const annotated = previousReleaseTag(tags, parseSemver("0.0.8").?).?;
try testing.expectEqual(parseSemver("0.0.7").?, annotated.version);
try testing.expectEqualStrings("bbb", annotated.object);
try testing.expect(annotated.peeled);
const reversed = previousReleaseTag(
"bbb\trefs/tags/v0.0.7^{}\naaa\trefs/tags/v0.0.7\n",
parseSemver("0.0.8").?,
).?;
try testing.expectEqualStrings("bbb", reversed.object);
// A lightweight tag has no peeled line, and its own id is the commit.
const lightweight = previousReleaseTag("ddd\trefs/tags/v0.0.9\n", parseSemver("1.0.0").?).?;
try testing.expectEqualStrings("ddd", lightweight.object);
try testing.expect(!lightweight.peeled);
// A first release has nothing to compare against.
try testing.expect(previousReleaseTag(tags, parseSemver("0.0.7").?) == null);
try testing.expect(previousReleaseTag("", parseSemver("1.0.0").?) == null);
// Non-release tags are not releases.
try testing.expect(previousReleaseTag("ggg\trefs/tags/nightly\n", parseSemver("1.0.0").?) == null);
try testing.expect(previousReleaseTag("ggg\trefs/tags/v0.0.8-rc1\n", parseSemver("1.0.0").?) == null);
}
test "a schema change is disclosed by a phrase in this version's own section" {
const source =
\\# Changelog
\\
\\## [0.0.10] - 2026-08-23
\\
\\- Upgrading resets your query history.
\\
\\## [0.0.9] - 2026-08-22
\\
\\- Something else.
\\
\\[0.0.10]: https://example.invalid/compare
;
try testing.expect(disclosesHistoryReset(changelogSection(source, "0.0.10").?));
// The disclosure belongs to the version that carries the change; another
// section's copy of the phrase is not this release's note.
try testing.expect(!disclosesHistoryReset(changelogSection(source, "0.0.9").?));
try testing.expect(changelogSection(source, "0.0.8") == null);
// The section stops at the link-reference block, not at the end of file.
try testing.expect(!disclosesHistoryReset(changelogSection(
"## [0.0.10] - 2026-08-23\n\n- A thing.\n\n[x]: resets your query history\n",
"0.0.10",
).?));
try testing.expect(!disclosesHistoryReset(""));
// The phrase is literal: a paraphrase does not clear the gate.
try testing.expect(!disclosesHistoryReset("- This wipes the query log."));
}
test "the runs listing decides appear, run, succeed and fail" { test "the runs listing decides appear, run, succeed and fail" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator); var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit(); defer arena_state.deinit();