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
+407 -16
View File
@@ -52,6 +52,11 @@ const Allocator = std.mem.Allocator;
const Io = std.Io;
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;
/// 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
/// tool that demanded today would make the operator lie in the file.
fn checkChangelog(source: []const u8, version: []const u8) ChangelogCheck {
var state: ChangelogCheck = .missing;
var in_section = false;
var body_seen = false;
const heading = changelogHeadingRest(source, version) orelse return .missing;
if (!isDateSuffix(heading)) return .undated;
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');
while (lines.next()) |raw| {
const line = std.mem.trimEnd(u8, raw, "\r");
if (versionHeadingRest(line, version)) |rest| return rest;
}
return null;
}
if (in_section) {
if (std.mem.startsWith(u8, line, "## ") or isLinkReference(line)) break;
if (!isBlank(line)) body_seen = true;
/// Everything below the `## [<version>]` heading and above whatever ends the
/// section: the next `## ` heading, or the Keep a Changelog link-reference
/// 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;
}
const rest = versionHeadingRest(line, version) orelse continue;
state = if (isDateSuffix(rest)) .ok else .undated;
if (state == .undated) return .undated;
in_section = true;
const body = std.mem.trimStart(u8, line, " \t");
if (std.mem.startsWith(u8, body, "\\\\")) {
parts.append(arena, body["\\\\".len..]) catch @panic("OOM");
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;
return if (body_seen) .ok else .empty;
/// A release tag as origin reports it: the version, and the object id to read
/// 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
@@ -1342,17 +1491,21 @@ fn preflight(ctx: *Ctx, version: []const u8, bump_needed: bool, plan: Plan) !Pre
ctx.pass("branch", "master", .{});
}
if (Io.Dir.cwd().readFileAlloc(ctx.io, "CHANGELOG.md", ctx.arena, .limited(max_input_bytes))) |changelog| {
switch (checkChangelog(changelog, version)) {
const changelog: ?[]const u8 = if (Io.Dir.cwd().readFileAlloc(ctx.io, "CHANGELOG.md", ctx.arena, .limited(max_input_bytes))) |source| source else |err| blk: {
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}),
.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}),
.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_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;
}
/// 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.
///
/// 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" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();