893231d4350a
fix nina update reporting success on no-op nix profile upgrade: match by element name not URL, detect nix's exit-0 no-op via nixUpgradeNoOp, drop dead originalUrl machinery
- time
- 2026-06-15 15:19 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- fix nina update false success
- parent
- 7b3809e8339a
↓ nina-893231d4.tar.gzatlas at this save
files changed4 files
VERSION+1 −1build.zig.zon+1 −1src/commands.zig+33 −15src/exec.zig+0 −84build.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.4.1",12+ .version = "3.4.2",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+33 −15
@@ -342,6 +342,14 @@342342 return ctx.gpa.dupe(u8, label) catch "";343343 }344344 345+// `nix profile upgrade` exits 0 even when its match argument names nothing —346+// it just prints one of these warnings and does no work. Detecting them keeps347+// `nina update` from reporting a false "updated" line on a no-op upgrade.348+fn nixUpgradeNoOp(s: []const u8) bool {349+ return std.mem.indexOf(u8, s, "No packages to upgrade") != null or350+ std.mem.indexOf(u8, s, "does not match any packages") != null;351+}352+345353 // nina update — kepr self-update for the nina binary itself.346354 pub fn update(ctx: Ctx) !void {347355 output.updateSelfStart();@@ -412,29 +420,30 @@412420 // element, so check the profile to avoid a no-op `upgrade` — it updates with413421 // the next nixos-rebuild instead.414422 if (std.mem.startsWith(u8, install_path, "/nix/store/")) {415- const nina_info = exec.profileNinaInfo(ctx.gpa, ctx.io, &ctx.machine);416- defer {417- if (nina_info) |n| {418- ctx.gpa.free(n.name);419- if (n.original_url) |u| ctx.gpa.free(u);420- }421- }422- if (nina_info == null) {423+ const nina_element = exec.profileNinaElement(ctx.gpa, ctx.io, &ctx.machine);424+ defer if (nina_element) |n| ctx.gpa.free(n);425+ if (nina_element == null) {423426 output.updateSelfNotInProfile();424427 return;425428 }426- const nina = nina_info.?;429+ const element = nina_element.?;427430 output.updateSelfNixManaged(remote_ver);428431 if (ctx.cfg.confirm and !output.confirm("")) return;429- // Pass originalUrl as the installable when available. Nix 2.18+ treats a430- // bare element name as a registry lookup (e.g. nixpkgs#nina) which silently431- // no-ops for tarball installs; the original URL targets the right source.432- const upgrade_arg = nina.original_url orelse nina.name;433- const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "upgrade", upgrade_arg }) catch {432+ // `nix profile upgrade` matches its argument against the profile element433+ // NAME, then re-fetches that element's own originalUrl. The name is the434+ // only thing that matches — passing the URL matches nothing and nix then435+ // exits 0 with a "No packages to upgrade" warning, so success must be436+ // judged on nix's output, not the exit code alone, or we report a false437+ // "updated" line for an upgrade that never happened.438+ const r = exec.capture(ctx.gpa, ctx.io, &ctx.machine, &.{ "nix", "profile", "upgrade", element }) catch {434439 output.updateSelfNixFailed();435440 return;436441 };437- if (ec != 0) {442+ defer ctx.gpa.free(r.stdout);443+ defer ctx.gpa.free(r.stderr);444+ output.raw(r.stdout);445+ output.raw(r.stderr);446+ if (r.exit_code != 0 or nixUpgradeNoOp(r.stdout) or nixUpgradeNoOp(r.stderr)) {438447 output.updateSelfNixFailed();439448 return;440449 }@@ -656,6 +665,15 @@656665 try std.testing.expect(versionInUpdateRange("3.2.0", "3.1.9", "3.2.3"));657666 try std.testing.expect(!versionInUpdateRange("3.1.9", "3.1.9", "3.2.3"));658667 try std.testing.expect(!versionInUpdateRange("3.2.4", "3.1.9", "3.2.3"));668+}669+670+test "nixUpgradeNoOp catches nix's exit-0 no-op warnings" {671+ // The exact strings from the screenshotted false-success bug.672+ try std.testing.expect(nixUpgradeNoOp("warning: No packages to upgrade. Use 'nix profile list'..."));673+ try std.testing.expect(nixUpgradeNoOp("warning: Package name 'https://kepr.uk/nina/archive/HEAD.tar.gz' does not match any packages in the profile."));674+ // A genuine upgrade's output must not be mistaken for a no-op.675+ try std.testing.expect(!nixUpgradeNoOp("building '/nix/store/...-nina-3.4.1.drv'...\n"));676+ try std.testing.expect(!nixUpgradeNoOp(""));659677 }660678 661679 test "remoteVersionNewer does not offer downgrades" {src/exec.zig+0 −84
@@ -692,90 +692,6 @@692692 return profileJsonNinaElement(gpa, js);693693 }694694 695-// Element name + install URL for the nina profile entry. Used by `nina update`696-// to upgrade via `nix profile upgrade <originalUrl>` rather than passing the697-// element name — newer nix (2.18+) interprets bare names as registry installables,698-// which silently no-ops for tarball installs. Caller owns both slices.699-pub const NinaProfileInfo = struct {700- name: []const u8,701- original_url: ?[]const u8,702-};703-704-pub fn profileNinaInfo(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ?NinaProfileInfo {705- const js = profileListJson(gpa, io, machine) catch return null;706- defer gpa.free(js);707- return profileJsonNinaInfo(gpa, js);708-}709-710-fn profileJsonNinaInfo(gpa: std.mem.Allocator, js: []const u8) ?NinaProfileInfo {711- var parsed = std.json.parseFromSlice(std.json.Value, gpa, js, .{}) catch return null;712- defer parsed.deinit();713- const root = parsed.value;714- if (root != .object) return null;715- const elements = root.object.get("elements") orelse return null;716- switch (elements) {717- .object => |obj| {718- // Fast path: "nina" key present (nix 2.18+ with nixpkgs#nina or tarball install).719- if (obj.contains("nina")) {720- const name = gpa.dupe(u8, "nina") catch return null;721- const url = urlFromObjEntry(gpa, obj.get("nina"));722- return .{ .name = name, .original_url = url };723- }724- // Fallback: scan storePaths for any element whose store path is nina.725- var it = obj.iterator();726- while (it.next()) |kv| {727- const val = kv.value_ptr.*;728- if (val != .object) continue;729- const sps = val.object.get("storePaths") orelse continue;730- if (sps != .array) continue;731- for (sps.array.items) |sp| {732- if (sp == .string and storePathIsNina(sp.string)) {733- const name = gpa.dupe(u8, kv.key_ptr.*) catch return null;734- const url = urlFromObjEntry(gpa, val);735- return .{ .name = name, .original_url = url };736- }737- }738- }739- return null;740- },741- .array => |arr| {742- for (arr.items) |el| {743- if (el != .object) continue;744- const ap = el.object.get("attrPath") orelse continue;745- if (ap != .string) continue;746- const is_nina_attr = std.mem.eql(u8, ap.string, "nina") or747- std.mem.endsWith(u8, ap.string, ".nina");748- if (!is_nina_attr) {749- const sps = el.object.get("storePaths") orelse continue;750- if (sps != .array) continue;751- var found = false;752- for (sps.array.items) |sp| {753- if (sp == .string and storePathIsNina(sp.string)) {754- found = true;755- break;756- }757- }758- if (!found) continue;759- }760- const dot = std.mem.lastIndexOf(u8, ap.string, ".") orelse 0;761- const el_name = if (!is_nina_attr and dot > 0) ap.string[dot + 1 ..] else "nina";762- const name = gpa.dupe(u8, el_name) catch return null;763- const url = urlFromObjEntry(gpa, el);764- return .{ .name = name, .original_url = url };765- }766- return null;767- },768- else => return null,769- }770-}771-772-fn urlFromObjEntry(gpa: std.mem.Allocator, val: ?std.json.Value) ?[]const u8 {773- const v = val orelse return null;774- if (v != .object) return null;775- const u = v.object.get("originalUrl") orelse return null;776- if (u != .string) return null;777- return gpa.dupe(u8, u.string) catch null;778-}779695 780696 pub fn profileHasNina(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) bool {781697 const name = profileNinaElement(gpa, io, machine) orelse return false;