63f60ea4c5c0
fix: Render update changelog fallback (v3.2.4)
- time
- 2026-06-12 21:02 UTC · 4 months ago
- machine
- koh2
- parent
- 9a3229ca965f
↓ nina-63f60ea4.tar.gzatlas at this save
files changed3 files
VERSION+1 −1build.zig.zon+1 −1src/commands.zig+161 −9build.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 allowssrc/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: