63f60ea4c5c0

fix: Render update changelog fallback (v3.2.4)

- Prefer release notes from metadata, then derive changelog entries from Kepr archives when notes are missing - Compare semver before offering an update so newer local builds do not downgrade - Bump VERSION/build.zig.zon to 3.2.4 - Verified zig build, zig build test, live update --check, and a local missing-notes update fixture

time
2026-06-12 21:02 UTC · 4 months ago
machine
koh2
parent
9a3229ca965f
3 files VERSION+1 −1build.zig.zon+1 −1src/commands.zig+161 −9
VERSION+1 −1
@@ -1 +1 @@1-3.2.31+3.2.4

view file

build.zig.zon+1 −1
@@ -9,7 +9,7 @@99     .name = .nina,1010     // This is a [Semantic Version](https://semver.org/).1111     // In a future version of Zig it will be used for package deduplication.12-    .version = "3.2.3",12+    .version = "3.2.4",1313     // Together with name, this represents a globally unique package1414     // identifier. This field is generated by the Zig toolchain when the1515     // package is first created, and then *never changes*. This allows

view file

src/commands.zig+161 −9
@@ -13,6 +13,12 @@1313 1414 const UPDATE_URL = "https://kepr.uk/api/nina/releases/latest";1515 16+const Semver = struct {17+    major: u32,18+    minor: u32,19+    patch: u32,20+};21+1622 pub const Ctx = struct {1723     gpa: std.mem.Allocator,1824     io: std.Io,@@ -382,19 +388,14 @@382388             },383389         };384390     };385-    const release_notes = blk: {386-        const v = root.object.get("release_notes") orelse break :blk "";387-        break :blk switch (v) {388-            .string => |s| s,389-            else => "",390-        };391-    };392-393-    if (std.mem.eql(u8, remote_ver, version.VERSION)) {391+    if (!remoteVersionNewer(version.VERSION, remote_ver)) {394392         output.updateSelfCurrent(version.VERSION);395393         return;396394     }397395 396+    const release_notes = try updateReleaseNotes(ctx.gpa, ctx.io, root, source_url, version.VERSION, remote_ver);397+    defer ctx.gpa.free(release_notes);398+398399     output.updateSelfAvailable(version.VERSION, remote_ver);399400     output.updateSelfReleaseNotes(release_notes);400401     if (ctx.check) return;@@ -499,6 +500,157 @@499500     };500501 501502     output.updateSelfDone(version.VERSION, remote_ver);503+}504+505+fn updateReleaseNotes(gpa: std.mem.Allocator, io: std.Io, root: std.json.Value, source_url: []const u8, current: []const u8, latest: []const u8) ![]u8 {506+    if (releaseNotesFromMetadata(gpa, root)) |notes| {507+        if (std.mem.trim(u8, notes, " \t\r\n").len > 0) return notes;508+        gpa.free(notes);509+    }510+    return buildKeprArchiveReleaseNotes(gpa, io, source_url, current, latest) catch gpa.dupe(u8, "");511+}512+513+fn releaseNotesFromMetadata(gpa: std.mem.Allocator, root: std.json.Value) ?[]u8 {514+    if (root != .object) return null;515+    const keys = [_][]const u8{ "release_notes", "changelog", "notes" };516+    for (keys) |key| {517+        const v = root.object.get(key) orelse continue;518+        switch (v) {519+            .string => |s| return gpa.dupe(u8, s) catch null,520+            else => {},521+        }522+    }523+    return null;524+}525+526+fn buildKeprArchiveReleaseNotes(gpa: std.mem.Allocator, io: std.Io, source_url: []const u8, current: []const u8, latest: []const u8) ![]u8 {527+    const archive_url = try keprArchiveUrl(gpa, source_url);528+    defer gpa.free(archive_url);529+530+    const body = exec.fetchUrl(gpa, io, archive_url) catch return gpa.dupe(u8, "");531+    defer gpa.free(body);532+533+    return releaseNotesFromArchiveJson(gpa, body, current, latest);534+}535+536+fn keprArchiveUrl(gpa: std.mem.Allocator, source_url: []const u8) ![]u8 {537+    const scheme = std.mem.indexOf(u8, source_url, "://") orelse return error.InvalidUpdateSource;538+    const path_start = std.mem.indexOfScalarPos(u8, source_url, scheme + 3, '/') orelse return error.InvalidUpdateSource;539+    const origin = source_url[0..path_start];540+    const path = source_url[path_start..];541+    if (!std.mem.startsWith(u8, path, "/api/")) return error.InvalidUpdateSource;542+543+    const after_api = path["/api/".len..];544+    const repo_end = std.mem.indexOfScalar(u8, after_api, '/') orelse return error.InvalidUpdateSource;545+    const repo = after_api[0..repo_end];546+    if (repo.len == 0) return error.InvalidUpdateSource;547+548+    return std.fmt.allocPrint(gpa, "{s}/{s}/archives.json?limit=40", .{ origin, repo });549+}550+551+fn releaseNotesFromArchiveJson(gpa: std.mem.Allocator, body: []const u8, current: []const u8, latest: []const u8) ![]u8 {552+    const parsed = std.json.parseFromSlice(std.json.Value, gpa, body, .{}) catch return gpa.dupe(u8, "");553+    defer parsed.deinit();554+555+    if (parsed.value != .object) return gpa.dupe(u8, "");556+    const saves_v = parsed.value.object.get("saves") orelse return gpa.dupe(u8, "");557+    if (saves_v != .array) return gpa.dupe(u8, "");558+559+    var out: std.ArrayList(u8) = .empty;560+    errdefer out.deinit(gpa);561+    var any = false;562+    for (saves_v.array.items) |save| {563+        if (save != .object) continue;564+        const ver_v = save.object.get("version") orelse continue;565+        const msg_v = save.object.get("message") orelse continue;566+        if (ver_v != .string or msg_v != .string) continue;567+        const ver = ver_v.string;568+        const msg = std.mem.trim(u8, msg_v.string, " \t\r\n");569+        if (msg.len == 0 or !versionInUpdateRange(ver, current, latest)) continue;570+571+        try appendFmt(&out, gpa, "## {s}\n", .{ver});572+        try appendFmt(&out, gpa, "- {s}\n\n", .{msg});573+        any = true;574+    }575+576+    if (!any) {577+        out.deinit(gpa);578+        return gpa.dupe(u8, "");579+    }580+    return out.toOwnedSlice(gpa);581+}582+583+fn appendFmt(out: *std.ArrayList(u8), gpa: std.mem.Allocator, comptime template: []const u8, args: anytype) !void {584+    const line = try std.fmt.allocPrint(gpa, template, args);585+    defer gpa.free(line);586+    try out.appendSlice(gpa, line);587+}588+589+fn versionInUpdateRange(candidate: []const u8, current: []const u8, latest: []const u8) bool {590+    const cand = parseSemver(candidate) orelse return std.mem.eql(u8, candidate, latest);591+    const cur = parseSemver(current) orelse return std.mem.eql(u8, candidate, latest);592+    const lat = parseSemver(latest) orelse return std.mem.eql(u8, candidate, latest);593+    return compareSemver(cand, cur) == .gt and compareSemver(cand, lat) != .gt;594+}595+596+fn remoteVersionNewer(current: []const u8, remote: []const u8) bool {597+    const cur = parseSemver(current) orelse return !std.mem.eql(u8, current, remote);598+    const rem = parseSemver(remote) orelse return !std.mem.eql(u8, current, remote);599+    return compareSemver(rem, cur) == .gt;600+}601+602+const VersionOrder = enum { lt, eq, gt };603+604+fn compareSemver(a: Semver, b: Semver) VersionOrder {605+    if (a.major != b.major) return if (a.major < b.major) .lt else .gt;606+    if (a.minor != b.minor) return if (a.minor < b.minor) .lt else .gt;607+    if (a.patch != b.patch) return if (a.patch < b.patch) .lt else .gt;608+    return .eq;609+}610+611+fn parseSemver(raw: []const u8) ?Semver {612+    const v = if (std.mem.startsWith(u8, raw, "v")) raw[1..] else raw;613+    var it = std.mem.splitScalar(u8, v, '.');614+    const major_s = it.next() orelse return null;615+    const minor_s = it.next() orelse return null;616+    const patch_s = it.next() orelse return null;617+    if (it.next() != null) return null;618+    return .{619+        .major = std.fmt.parseInt(u32, major_s, 10) catch return null,620+        .minor = std.fmt.parseInt(u32, minor_s, 10) catch return null,621+        .patch = std.fmt.parseInt(u32, patch_s, 10) catch return null,622+    };623+}624+625+test "versionInUpdateRange includes only newer versions through latest" {626+    try std.testing.expect(versionInUpdateRange("3.2.3", "3.1.9", "3.2.3"));627+    try std.testing.expect(versionInUpdateRange("3.2.0", "3.1.9", "3.2.3"));628+    try std.testing.expect(!versionInUpdateRange("3.1.9", "3.1.9", "3.2.3"));629+    try std.testing.expect(!versionInUpdateRange("3.2.4", "3.1.9", "3.2.3"));630+}631+632+test "remoteVersionNewer does not offer downgrades" {633+    try std.testing.expect(remoteVersionNewer("3.2.3", "3.2.4"));634+    try std.testing.expect(!remoteVersionNewer("3.2.4", "3.2.3"));635+    try std.testing.expect(!remoteVersionNewer("3.2.4", "3.2.4"));636+}637+638+test "releaseNotesFromArchiveJson builds changelog from versioned saves" {639+    const body =640+        \\{641+        \\  "saves": [642+        \\    {"version":"3.2.3","message":"upgrade: live build panel during rebuild phase"},643+        \\    {"version":"3.1.9","message":"old release"},644+        \\    {"version":null,"message":"unversioned"}645+        \\  ]646+        \\}647+    ;648+    const notes = try releaseNotesFromArchiveJson(std.testing.allocator, body, "3.1.9", "3.2.3");649+    defer std.testing.allocator.free(notes);650+651+    try std.testing.expect(std.mem.indexOf(u8, notes, "## 3.2.3") != null);652+    try std.testing.expect(std.mem.indexOf(u8, notes, "- upgrade: live build panel") != null);653+    try std.testing.expect(std.mem.indexOf(u8, notes, "old release") == null);502654 }503655 504656 // nina goodbye — clean uninstall. Three install paths:

view file