diff --git a/src/cli/bunfig.zig b/src/cli/bunfig.zig index 0f7c65fbc53..667b891c912 100644 --- a/src/cli/bunfig.zig +++ b/src/cli/bunfig.zig @@ -509,7 +509,7 @@ pub const Bunfig = struct { } } - if (comptime cmd.isNPMRelated() or cmd == .RunCommand or cmd == .AutoCommand or cmd == .TestCommand) { + if (comptime cmd.isNPMRelated() or cmd == .RunCommand or cmd == .AutoCommand or cmd == .TestCommand or cmd == .UpgradeCommand) { if (json.getObject("install")) |install_obj| { var install: *api.BunInstall = this.ctx.install orelse brk: { const install = try this.allocator.create(api.BunInstall); diff --git a/src/cli/upgrade_command.zig b/src/cli/upgrade_command.zig index 1bc575bba41..c0f75351869 100644 --- a/src/cli/upgrade_command.zig +++ b/src/cli/upgrade_command.zig @@ -87,6 +87,117 @@ pub const UpgradeCommand = struct { var unzip_path_buf: bun.PathBuffer = undefined; var tmpdir_path_buf: bun.PathBuffer = undefined; + /// Result of parsing a single GitHub release entry for the current + /// platform's matching asset. `published_at_ms` is the release's + /// `published_at` timestamp converted to epoch ms, or `null` when + /// GitHub did not return the field (e.g. in mocked responses). + const ParsedRelease = struct { + version: Version, + published_at_ms: ?f64, + }; + + /// Fill in `version.zip_url` / `version.size` by walking a release's + /// `assets` array looking for the zip matching this platform. Returns + /// `true` if a matching asset was found. `version.tag` must already be + /// populated by the caller. + fn findAssetInRelease( + allocator: std.mem.Allocator, + release_expr: js_ast.Expr, + use_profile: bool, + version: *Version, + ) bool { + const assets_ = release_expr.asProperty("assets") orelse return false; + var assets = assets_.expr.asArray() orelse return false; + + while (assets.next()) |asset| { + if (asset.asProperty("content_type")) |content_type| { + const content_type_ = (content_type.expr.asString(allocator)) orelse continue; + if (comptime Environment.isDebug) { + Output.prettyln("Content-type: {s}", .{content_type_}); + Output.flush(); + } + + if (!strings.eqlComptime(content_type_, "application/zip")) continue; + } + + if (asset.asProperty("name")) |name_| { + if (name_.expr.asString(allocator)) |name| { + if (comptime Environment.isDebug) { + const filename = if (!use_profile) Version.zip_filename else Version.profile_zip_filename; + Output.prettyln("Comparing {s} vs {s}", .{ name, filename }); + Output.flush(); + } + + if (!use_profile and !strings.eqlComptime(name, Version.zip_filename)) continue; + if (use_profile and !strings.eqlComptime(name, Version.profile_zip_filename)) continue; + + version.zip_url = (asset.asProperty("browser_download_url") orelse return false).expr.asString(allocator) orelse return false; + if (comptime Environment.isDebug) { + Output.prettyln("Found Zip {s}", .{version.zip_url}); + Output.flush(); + } + + if (asset.asProperty("size")) |size_| { + if (size_.expr.data == .e_number) { + version.size = @as(u32, @intCast(@max(@as(i32, @intFromFloat(std.math.ceil(size_.expr.data.e_number.value))), 0))); + } + } + return true; + } + } + } + return false; + } + + /// Parse a single release object (as returned by the GitHub releases + /// API) and extract tag, matching asset, and publish timestamp. + /// + /// Returns `null` when the release lacks a usable `tag_name` or a + /// matching platform asset. `version.buf` is not set here — the + /// caller owns the HTTP response body. + fn parseReleaseObject( + allocator: std.mem.Allocator, + release_expr: js_ast.Expr, + use_profile: bool, + ) ?ParsedRelease { + if (release_expr.data != .e_object) return null; + + var version = Version{ .zip_url = "", .tag = "", .buf = MutableString.initEmpty(allocator), .size = 0 }; + + if (release_expr.asProperty("tag_name")) |tag_name_| { + if (tag_name_.expr.asString(allocator)) |tag_name| { + version.tag = tag_name; + } + } + + if (version.tag.len == 0) return null; + + var published_at_ms: ?f64 = null; + if (release_expr.asProperty("published_at")) |published_at_expr| { + if (published_at_expr.expr.asString(allocator)) |published_at_str| { + if (published_at_str.len > 0) { + if (bun.jsc.wtf.parseES5Date(published_at_str) catch null) |ms| { + published_at_ms = ms; + } + } + } + } + + if (!findAssetInRelease(allocator, release_expr, use_profile, &version)) return null; + + return .{ .version = version, .published_at_ms = published_at_ms }; + } + + /// Epoch-ms "now" consistent with how the install-side resolver + /// computes it from `bun.start_time`. Used to age-gate upgrades. + fn currentTimestampMs() f64 { + return @floatFromInt(@divTrunc(bun.start_time, std.time.ns_per_ms)); + } + + fn isReleaseTooRecent(published_at_ms: f64, minimum_release_age_ms: f64) bool { + return published_at_ms > currentTimestampMs() - minimum_release_age_ms; + } + pub fn getLatestVersion( allocator: std.mem.Allocator, env_loader: *DotEnv.Loader, @@ -94,6 +205,7 @@ pub const UpgradeCommand = struct { progress: ?*Progress.Node, use_profile: bool, comptime silent: bool, + minimum_release_age_ms: ?f64, ) !?Version { var headers_buf: string = default_github_headers; // gonna have to free memory myself like a goddamn caveman due to a thread safety issue with ArenaAllocator @@ -184,7 +296,7 @@ pub const UpgradeCommand = struct { defer if (comptime silent) log.deinit(); const source = &logger.Source.initPathString("releases.json", metadata_body.list.items); initializeStore(); - var expr = JSON.parseUTF8(source, &log, allocator) catch |err| { + const expr = JSON.parseUTF8(source, &log, allocator) catch |err| { if (!silent) { progress.?.end(); refresher.?.refresh(); @@ -214,8 +326,6 @@ pub const UpgradeCommand = struct { return null; } - var version = Version{ .zip_url = "", .tag = "", .buf = metadata_body, .size = 0 }; - if (expr.data != .e_object) { if (!silent) { progress.?.end(); @@ -229,77 +339,137 @@ pub const UpgradeCommand = struct { return null; } - if (expr.asProperty("tag_name")) |tag_name_| { - if (tag_name_.expr.asString(allocator)) |tag_name| { - version.tag = tag_name; - } - } - - if (version.tag.len == 0) { + const latest = parseReleaseObject(allocator, expr, use_profile) orelse { if (comptime !silent) { progress.?.end(); refresher.?.refresh(); - Output.prettyErrorln("JSON Error parsing releases from GitHub: tag_name is missing?\n{s}", .{metadata_body.list.items}); - Global.exit(1); + // Distinguish "no tag_name" from "no matching asset" for clearer errors. + const has_tag = if (expr.asProperty("tag_name")) |t| t.expr.asString(allocator) != null else false; + if (!has_tag) { + Output.prettyErrorln("JSON Error parsing releases from GitHub: tag_name is missing?\n{s}", .{metadata_body.list.items}); + Global.exit(1); + } + // Tag name is present but no asset for this platform. + var tmp = Version{ .zip_url = "", .tag = "", .buf = MutableString.initEmpty(allocator), .size = 0 }; + if (expr.asProperty("tag_name")) |t| { + if (t.expr.asString(allocator)) |s| tmp.tag = s; + } + if (tmp.name()) |name| { + Output.prettyErrorln("Bun v{s} is out, but not for this platform ({s}) yet.", .{ + name, Version.triplet, + }); + } + Global.exit(0); } return null; - } + }; - get_asset: { - const assets_ = expr.asProperty("assets") orelse break :get_asset; - var assets = assets_.expr.asArray() orelse break :get_asset; + // Honor `install.minimumReleaseAge`: if the latest release was + // published too recently, fall back to the releases list endpoint + // and pick the newest release that passes the window. + if (minimum_release_age_ms) |min_age_ms| { + if (min_age_ms > 0 and latest.published_at_ms != null and isReleaseTooRecent(latest.published_at_ms.?, min_age_ms)) { + if (try findEligibleVersionFromReleasesList(allocator, env_loader, progress, use_profile, silent, github_api_domain, header_entries, headers_buf, min_age_ms)) |fallback| { + return fallback; + } - while (assets.next()) |asset| { - if (asset.asProperty("content_type")) |content_type| { - const content_type_ = (content_type.expr.asString(allocator)) orelse continue; - if (comptime Environment.isDebug) { - Output.prettyln("Content-type: {s}", .{content_type_}); - Output.flush(); - } + if (comptime !silent) { + progress.?.end(); + refresher.?.refresh(); - if (!strings.eqlComptime(content_type_, "application/zip")) continue; + const age_seconds = min_age_ms / std.time.ms_per_s; + if (latest.version.name()) |name| { + Output.prettyErrorln( + "error: No Bun release is older than the configured minimumReleaseAge ({d}s). Latest release is v{s}.", + .{ age_seconds, name }, + ); + } else { + Output.prettyErrorln( + "error: No Bun release is older than the configured minimumReleaseAge ({d}s).", + .{age_seconds}, + ); + } + Global.exit(1); } + return null; + } + } - if (asset.asProperty("name")) |name_| { - if (name_.expr.asString(allocator)) |name| { - if (comptime Environment.isDebug) { - const filename = if (!use_profile) Version.zip_filename else Version.profile_zip_filename; - Output.prettyln("Comparing {s} vs {s}", .{ name, filename }); - Output.flush(); - } + var v = latest.version; + v.buf = metadata_body; + return v; + } - if (!use_profile and !strings.eqlComptime(name, Version.zip_filename)) continue; - if (use_profile and !strings.eqlComptime(name, Version.profile_zip_filename)) continue; + /// Fallback used when the latest release is inside the + /// `minimumReleaseAge` window. Fetches `/releases?per_page=10` + /// (newest first) and returns the newest release whose + /// `published_at` is older than `minimum_release_age_ms`. + fn findEligibleVersionFromReleasesList( + allocator: std.mem.Allocator, + env_loader: *DotEnv.Loader, + progress: ?*Progress.Node, + use_profile: bool, + comptime silent: bool, + github_api_domain: string, + header_entries: Headers.Entry.List, + headers_buf: string, + minimum_release_age_ms: f64, + ) !?Version { + var list_url_buf: bun.PathBuffer = undefined; + const list_url = URL.parse( + try std.fmt.bufPrint( + &list_url_buf, + "https://{s}/repos/Jarred-Sumner/bun-releases-for-updater/releases?per_page=10", + .{github_api_domain}, + ), + ); - version.zip_url = (asset.asProperty("browser_download_url") orelse break :get_asset).expr.asString(allocator) orelse break :get_asset; - if (comptime Environment.isDebug) { - Output.prettyln("Found Zip {s}", .{version.zip_url}); - Output.flush(); - } + const http_proxy: ?URL = env_loader.getHttpProxyFor(list_url); - if (asset.asProperty("size")) |size_| { - if (size_.expr.data == .e_number) { - version.size = @as(u32, @intCast(@max(@as(i32, @intFromFloat(std.math.ceil(size_.expr.data.e_number.value))), 0))); - } - } - return version; - } - } - } + var body = try MutableString.init(allocator, 8192); + + var async_http: *HTTP.AsyncHTTP = try allocator.create(HTTP.AsyncHTTP); + async_http.* = HTTP.AsyncHTTP.initSync( + allocator, + .GET, + list_url, + header_entries, + headers_buf, + &body, + "", + http_proxy, + null, + HTTP.FetchRedirect.follow, + ); + async_http.client.flags.reject_unauthorized = env_loader.getTLSRejectUnauthorized(); + if (!silent) async_http.client.progress_node = progress.?; + + const response = try async_http.sendSync(); + switch (response.status_code) { + 200 => {}, + else => return null, // fall back to "no eligible release" } - if (comptime !silent) { - progress.?.end(); - refresher.?.refresh(); - if (version.name()) |name| { - Output.prettyErrorln("Bun v{s} is out, but not for this platform ({s}) yet.", .{ - name, Version.triplet, - }); - } + var log = logger.Log.init(allocator); + defer if (comptime silent) log.deinit(); - Global.exit(0); + const source = &logger.Source.initPathString("releases-list.json", body.list.items); + initializeStore(); + const list_expr = JSON.parseUTF8(source, &log, allocator) catch return null; + if (log.errors > 0) return null; + + var releases = list_expr.asArray() orelse return null; + while (releases.next()) |release_expr| { + const parsed = parseReleaseObject(allocator, release_expr, use_profile) orelse continue; + // Skip releases we can't age-gate (missing published_at). + const published_at_ms = parsed.published_at_ms orelse continue; + if (isReleaseTooRecent(published_at_ms, minimum_release_age_ms)) continue; + + var v = parsed.version; + v.buf = body; + return v; } return null; @@ -373,11 +543,16 @@ pub const UpgradeCommand = struct { const use_profile = strings.containsAny(bun.argv, "--profile"); + // `install.minimumReleaseAge` (seconds → ms) from bunfig, if any. + // Only applies to the stable channel — canary upgrades don't use + // published_at timestamps and are opted into explicitly. + const minimum_release_age_ms: ?f64 = if (ctx.install) |install_config| install_config.minimum_release_age_ms else null; + var version: Version = if (!use_canary) v: { var refresher = Progress{}; var progress = refresher.start("Fetching version tags", 0); - const version = (try getLatestVersion(ctx.allocator, &env_loader, &refresher, progress, use_profile, false)) orelse return; + const version = (try getLatestVersion(ctx.allocator, &env_loader, &refresher, progress, use_profile, false, minimum_release_age_ms)) orelse return; progress.end(); refresher.refresh(); diff --git a/src/options_types/CommandTag.zig b/src/options_types/CommandTag.zig index 78b443c9d41..32edb6f9155 100644 --- a/src/options_types/CommandTag.zig +++ b/src/options_types/CommandTag.zig @@ -93,6 +93,7 @@ pub const Tag = enum { .OutdatedCommand, .PublishCommand, .AuditCommand, + .UpgradeCommand, => true, else => false, }; @@ -136,6 +137,7 @@ pub const Tag = enum { .UpdateInteractiveCommand = true, .PublishCommand = true, .AuditCommand = true, + .UpgradeCommand = true, }); pub const always_loads_config: std.EnumArray(Tag, bool) = std.EnumArray(Tag, bool).initDefault(false, .{ @@ -153,6 +155,7 @@ pub const Tag = enum { .UpdateInteractiveCommand = true, .PublishCommand = true, .AuditCommand = true, + .UpgradeCommand = true, }); pub const uses_global_options: std.EnumArray(Tag, bool) = std.EnumArray(Tag, bool).initDefault(true, .{ diff --git a/test/cli/install/bun-upgrade.test.ts b/test/cli/install/bun-upgrade.test.ts index 15a2739ba93..e5eedc46bd0 100644 --- a/test/cli/install/bun-upgrade.test.ts +++ b/test/cli/install/bun-upgrade.test.ts @@ -2,10 +2,35 @@ import { spawn } from "bun"; import { upgrade_test_helpers } from "bun:internal-for-testing"; import { beforeAll, describe, expect, it, setDefaultTimeout } from "bun:test"; import { bunExe, bunEnv as env, tls, tmpdirSync } from "harness"; -import { copyFile } from "node:fs/promises"; +import { copyFile, writeFile } from "node:fs/promises"; import { basename, join } from "path"; const { openTempDirWithoutSharingDelete, closeTempDirHandle } = upgrade_test_helpers; +// All supported `bun upgrade` asset names for a given tag, mirroring +// what the real GitHub releases API would return. +function allPlatformAssets(tagName: string) { + const assetNames = [ + "bun-windows-x64.zip", + "bun-windows-x64-baseline.zip", + "bun-windows-aarch64.zip", + "bun-linux-x64.zip", + "bun-linux-x64-baseline.zip", + "bun-linux-aarch64.zip", + "bun-linux-x64-musl.zip", + "bun-linux-x64-musl-baseline.zip", + "bun-linux-aarch64-musl.zip", + "bun-darwin-x64.zip", + "bun-darwin-x64-baseline.zip", + "bun-darwin-aarch64.zip", + ]; + return assetNames.map(name => ({ + url: "foo", + content_type: "application/zip", + name, + browser_download_url: `https://pub-5e11e972747a44bf9aaf9394f185a982.r2.dev/releases/${tagName}/${name}`, + })); +} + beforeAll(() => { setDefaultTimeout(1000 * 60 * 5); }); @@ -194,4 +219,212 @@ describe.concurrent(() => { // Should not contain error message expect(await stderr.text()).not.toContain("error:"); }); + + // When `install.minimumReleaseAge` is set and the latest release was + // published too recently, `bun upgrade` should fall back to the + // `/releases` list endpoint and pick the newest release older than the + // configured window. + // + // `--stable` is passed so debug builds (which default to the canary + // channel and skip `getLatestVersion` entirely) still exercise the + // stable resolution path where `minimumReleaseAge` applies. + it("honors install.minimumReleaseAge: falls back to older release when latest is too recent", async () => { + const now = Date.now(); + // Window is 10 minutes; the "latest" release is 1 minute old (too + // recent) and the fallback is 1 hour old (eligible). + const newPublishedAt = new Date(now - 60_000).toISOString(); + const oldPublishedAt = new Date(now - 60 * 60_000).toISOString(); + // Use the running Bun's version as the fallback tag so `isCurrent()` + // short-circuits with the "already on latest" message instead of + // actually attempting to download anything. + const fallbackTag = `bun-v${Bun.version}`; + + using server = Bun.serve({ + tls, + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname.endsWith("/releases/latest")) { + return new Response( + JSON.stringify({ + tag_name: "bun-v999.999.999", + published_at: newPublishedAt, + assets: allPlatformAssets("bun-v999.999.999"), + }), + ); + } + // `/releases?per_page=10` — newest-first array. + if (url.pathname.endsWith("/releases")) { + return new Response( + JSON.stringify([ + { + tag_name: "bun-v999.999.999", + published_at: newPublishedAt, + assets: allPlatformAssets("bun-v999.999.999"), + }, + { + tag_name: fallbackTag, + published_at: oldPublishedAt, + assets: allPlatformAssets(fallbackTag), + }, + ]), + ); + } + return new Response("not found", { status: 404 }); + }, + }); + + const cwd = tmpdirSync(); + const execPath = join(cwd, basename(bunExe())); + await copyFile(bunExe(), execPath); + await writeFile(join(cwd, "bunfig.toml"), `[install]\nminimumReleaseAge = 600 # 10 minutes\n`); + + const { stdout, stderr, exited } = Bun.spawn({ + cmd: [execPath, "upgrade", "--stable"], + cwd, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { + ...env, + NODE_TLS_REJECT_UNAUTHORIZED: "0", + GITHUB_API_DOMAIN: `${server.hostname}:${server.port}`, + }, + }); + + const [err, _out, _code] = await Promise.all([stderr.text(), stdout.text(), exited]); + // Skipped the too-recent v999.999.999 and landed on the older tag. + // The actual download will then fail because the asset URL points + // at a fake CDN host — that's fine; this test only cares which + // version was *selected*. + expect(err).not.toContain("v999.999.999"); + expect(err).toContain(`v${Bun.version}`); + }); + + // Same setup, but the `/releases` list contains only too-recent + // releases. `bun upgrade` must refuse and exit non-zero with a clear + // message instead of silently downloading the too-recent build. + it("honors install.minimumReleaseAge: errors when no release passes the window", async () => { + const now = Date.now(); + const newPublishedAt = new Date(now - 60_000).toISOString(); // 1 min ago + + using server = Bun.serve({ + tls, + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname.endsWith("/releases/latest")) { + return new Response( + JSON.stringify({ + tag_name: "bun-v999.999.999", + published_at: newPublishedAt, + assets: allPlatformAssets("bun-v999.999.999"), + }), + ); + } + if (url.pathname.endsWith("/releases")) { + return new Response( + JSON.stringify([ + { + tag_name: "bun-v999.999.999", + published_at: newPublishedAt, + assets: allPlatformAssets("bun-v999.999.999"), + }, + { + tag_name: "bun-v999.999.998", + published_at: newPublishedAt, + assets: allPlatformAssets("bun-v999.999.998"), + }, + ]), + ); + } + return new Response("not found", { status: 404 }); + }, + }); + + const cwd = tmpdirSync(); + const execPath = join(cwd, basename(bunExe())); + await copyFile(bunExe(), execPath); + await writeFile( + join(cwd, "bunfig.toml"), + // 1-day window so the 1-minute-old fake releases are both excluded. + `[install]\nminimumReleaseAge = 86400\n`, + ); + + const { stderr, exited } = Bun.spawn({ + cmd: [execPath, "upgrade", "--stable"], + cwd, + stdin: "pipe", + stdout: null, + stderr: "pipe", + env: { + ...env, + NODE_TLS_REJECT_UNAUTHORIZED: "0", + GITHUB_API_DOMAIN: `${server.hostname}:${server.port}`, + }, + }); + + const [err, code] = await Promise.all([stderr.text(), exited]); + expect(err).toContain("minimumReleaseAge"); + expect(code).not.toBe(0); + }); + + // Sanity check: when the latest release is already older than the + // configured window, `bun upgrade` uses it directly — no extra request + // to the list endpoint. + it("honors install.minimumReleaseAge: uses latest when it already passes the window", async () => { + const tagNameCurrent = `bun-v${Bun.version}`; + const publishedAt = new Date(Date.now() - 24 * 60 * 60_000).toISOString(); // 1 day ago + + let listEndpointHit = false; + using server = Bun.serve({ + tls, + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname.endsWith("/releases/latest")) { + return new Response( + JSON.stringify({ + tag_name: tagNameCurrent, + published_at: publishedAt, + assets: allPlatformAssets(tagNameCurrent), + }), + ); + } + if (url.pathname.endsWith("/releases")) { + listEndpointHit = true; + return new Response("[]"); + } + return new Response("not found", { status: 404 }); + }, + }); + + const cwd = tmpdirSync(); + const execPath = join(cwd, basename(bunExe())); + await copyFile(bunExe(), execPath); + await writeFile(join(cwd, "bunfig.toml"), `[install]\nminimumReleaseAge = 600 # 10 min\n`); + + const { stderr, exited } = Bun.spawn({ + cmd: [execPath, "upgrade", "--stable"], + cwd, + stdin: "pipe", + stdout: null, + stderr: "pipe", + env: { + ...env, + NODE_TLS_REJECT_UNAUTHORIZED: "0", + GITHUB_API_DOMAIN: `${server.hostname}:${server.port}`, + }, + }); + + const [err, _code] = await Promise.all([stderr.text(), exited]); + // Latest release already satisfies the window, so only + // `/releases/latest` should be hit — the list endpoint is the + // fallback and must not fire here. + expect(listEndpointHit).toBe(false); + // Confirm the intended version was picked (download itself will + // still fail against the fake CDN — ignore the exit code). + expect(err).toContain(`v${Bun.version}`); + expect(err).not.toContain("minimumReleaseAge"); + }); });