|
|
|
@@ -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();
|
|
|
|
|