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:
+407
-16
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user