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
4 files VERSION+1 −1build.zig.zon+1 −1src/commands.zig+33 −15src/exec.zig+0 −84
VERSION+1 −1
@@ -1 +1 @@1-3.4.11+3.4.2

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.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 allows

view file

src/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" {

view file

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;

view file