bun upgrade: honor install.minimumReleaseAge#30534
Conversation
When `install.minimumReleaseAge` is configured in bunfig.toml, `bun upgrade` now filters GitHub releases the same way `bun install` and `bun update` filter npm package versions. - If the latest Bun release is older than the configured window, use it as before (only costs one extra JSON field read). - If the latest release was published too recently, fall back to the `/releases?per_page=10` endpoint and pick the newest release whose `published_at` is outside the window. - If no release in that list passes the window, refuse to upgrade with a clear error naming the configured age. Canary upgrades are unaffected — they're opted into explicitly and use a fixed zip URL, not the releases API. Closes #30533
|
Updated 11:05 PM PT - May 11th, 2026
❌ @robobun, your commit f4cb3da has 2 failures in
🧪 To try this PR locally: bunx bun-pr 30534That installs a local version of the PR into your bun-30534 --bun |
WalkthroughThis PR adds ChangesMinimum Release Age Gating
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/cli/upgrade_command.zig`:
- Around line 450-453: The switch on response.status_code inside the fallback
/releases handler currently maps every non‑200 to "return null", hiding HTTP
errors; change it to propagate HTTP failures like the primary path does instead
of collapsing them to a missing eligible release. Specifically, in the same
scope where you inspect response.status_code (the switch around
response.status_code), handle 200 as success and for other status codes return
or propagate an appropriate Zig error/result that includes the HTTP status
(mirroring the behavior used for /releases/latest), so rate limits/auth/server
errors surface instead of being treated as a minimumReleaseAge miss.
- Around line 372-374: The current check skips age enforcement when
latest.published_at_ms is null; change the condition in the upgrade flow so that
when minimum_release_age_ms is set (>0) a missing published_at_ms is treated as
ineligible. Replace the existing conjunctive check using latest.published_at_ms
!= null and isReleaseTooRecent(...) with a check that triggers fallback when
min_age_ms > 0 and (latest.published_at_ms == null or
isReleaseTooRecent(latest.published_at_ms.?, min_age_ms)); keep the call to
findEligibleVersionFromReleasesList(...) as the fallback path so unknown publish
times force fallback/error.
In `@test/cli/install/bun-upgrade.test.ts`:
- Around line 277-280: Replace uses of tmpdirSync with the test harness tempDir
helper: instead of calling tmpdirSync() to set cwd, call tempDir() from the
harness and use that value for cwd (so execPath = join(cwd, basename(bunExe()))
remains correct). Update all occurrences in this file (the blocks that create
cwd, execPath, copyFile(bunExe(), execPath) and writeFile(join(cwd,
"bunfig.toml"), ...)) to use tempDir; ensure imports or test harness setup
provide tempDir and that cleanup/lifecycle follow existing test patterns.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: f04c9182-198e-45f8-9582-c2a30df62215
📒 Files selected for processing (4)
src/cli/bunfig.zigsrc/cli/upgrade_command.zigsrc/options_types/CommandTag.zigtest/cli/install/bun-upgrade.test.ts
| 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| { |
There was a problem hiding this comment.
Fail closed when minimumReleaseAge is set but published_at is missing.
Line 373 currently skips enforcement when latest.published_at_ms is null, which allows a policy bypass. With minimumReleaseAge enabled, unknown publish time should be treated as ineligible and force fallback/error.
Suggested fix
- 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 (minimum_release_age_ms) |min_age_ms| {
+ if (min_age_ms > 0) {
+ const latest_is_ineligible = if (latest.published_at_ms) |published_ms|
+ isReleaseTooRecent(published_ms, min_age_ms)
+ else
+ true; // fail closed when timestamp is unavailable
+
+ if (latest_is_ineligible) {
if (try findEligibleVersionFromReleasesList(allocator, env_loader, progress, use_profile, silent, github_api_domain, header_entries, headers_buf, min_age_ms)) |fallback| {
return fallback;
}
@@
- return null;
- }
+ return null;
+ }
+ }
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/cli/upgrade_command.zig` around lines 372 - 374, The current check skips
age enforcement when latest.published_at_ms is null; change the condition in the
upgrade flow so that when minimum_release_age_ms is set (>0) a missing
published_at_ms is treated as ineligible. Replace the existing conjunctive check
using latest.published_at_ms != null and isReleaseTooRecent(...) with a check
that triggers fallback when min_age_ms > 0 and (latest.published_at_ms == null
or isReleaseTooRecent(latest.published_at_ms.?, min_age_ms)); keep the call to
findEligibleVersionFromReleasesList(...) as the fallback path so unknown publish
times force fallback/error.
| switch (response.status_code) { | ||
| 200 => {}, | ||
| else => return null, // fall back to "no eligible release" | ||
| } |
There was a problem hiding this comment.
Propagate fallback endpoint HTTP failures instead of collapsing them to “no eligible release”.
Line 452 returns null for every non-200 from /releases, so rate-limit/auth/server failures are misreported as a minimumReleaseAge miss. Propagate specific HTTP errors like the primary /releases/latest path does.
Suggested fix
const response = try async_http.sendSync();
switch (response.status_code) {
200 => {},
- else => return null, // fall back to "no eligible release"
+ 404 => return error.HTTP404,
+ 403 => return error.HTTPForbidden,
+ 429 => return error.HTTPTooManyRequests,
+ 499...599 => return error.GitHubIsDown,
+ else => return error.HTTPError,
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/cli/upgrade_command.zig` around lines 450 - 453, The switch on
response.status_code inside the fallback /releases handler currently maps every
non‑200 to "return null", hiding HTTP errors; change it to propagate HTTP
failures like the primary path does instead of collapsing them to a missing
eligible release. Specifically, in the same scope where you inspect
response.status_code (the switch around response.status_code), handle 200 as
success and for other status codes return or propagate an appropriate Zig
error/result that includes the HTTP status (mirroring the behavior used for
/releases/latest), so rate limits/auth/server errors surface instead of being
treated as a minimumReleaseAge miss.
| 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`); |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n --type=ts -C1 '\btmpdirSync\(' test/cli/install/bun-upgrade.test.tsRepository: oven-sh/bun
Length of output: 1274
Use tempDir from harness instead of tmpdirSync in new test cases.
The new test cases at lines 277–280, 345–348, and 402–405 create temporary directories using tmpdirSync. Per coding guidelines for test/**/*.test.ts, use tempDir from harness instead to align with cleanup and lifecycle conventions.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test/cli/install/bun-upgrade.test.ts` around lines 277 - 280, Replace uses of
tmpdirSync with the test harness tempDir helper: instead of calling tmpdirSync()
to set cwd, call tempDir() from the harness and use that value for cwd (so
execPath = join(cwd, basename(bunExe())) remains correct). Update all
occurrences in this file (the blocks that create cwd, execPath,
copyFile(bunExe(), execPath) and writeFile(join(cwd, "bunfig.toml"), ...)) to
use tempDir; ensure imports or test harness setup provide tempDir and that
cleanup/lifecycle follow existing test patterns.
|
|
||
| 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; |
There was a problem hiding this comment.
🔴 The fallback loop returns the first release whose published_at is outside the window with no other filtering, so it can return a release older than the currently-installed Bun — and since _exec only checks version.isCurrent() (exact equality, not >), bun upgrade will then silently downgrade the binary. The loop should skip candidates whose tag is <= the current version (or _exec should treat a selected version < current as "already on latest"); it would also be worth skipping entries with prerelease: true, since GET /releases includes prereleases whereas the primary /releases/latest path does not.
Extended reasoning...
What the bug is
findEligibleVersionFromReleasesList iterates the newest-first GET /releases?per_page=10 response and returns the first entry whose published_at falls outside the minimumReleaseAge window:
while (releases.next()) |release_expr| {
const parsed = parseReleaseObject(allocator, release_expr, use_profile) orelse continue;
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;
}There is no comparison against the currently-installed version, and no check of the prerelease / draft flags. Back in _exec (src/cli/upgrade_command.zig:560-568), the only post-resolution guard is:
if (version.name() != null and version.isCurrent()) {
Output.prettyErrorln("Congrats! You're already on the latest version of Bun ...");
Global.exit(0);
}isCurrent() is exact string equality with "bun-v" ++ Global.package_json_version. There is no semver > comparison anywhere in the upgrade path. Previously this was safe because getLatestVersion only ever returned the result of /releases/latest, which is by construction >= the installed version, so the only outcomes were "exact match → already latest" or "newer → upgrade". The new fallback path breaks that invariant.
Step-by-step proof (downgrade)
- User installs Bun via
curl -fsSL https://bun.com/install | bash(or homebrew/npm). These installers ignore bunfig and install the latest release — sayv1.2.5, published 3 days ago. - User has
[install] minimumReleaseAge = 604800(7 days) in a project-localbunfig.tomlfor npm supply-chain safety. - User runs
bun upgradefrom that directory. getLatestVersionfetches/releases/latest→v1.2.6(or evenv1.2.5itself), published < 7 days ago →isReleaseTooRecentis true → enters the fallback.findEligibleVersionFromReleasesListwalks/releasesnewest-first:v1.2.6(2 days, skip),v1.2.5(3 days, skip),v1.2.4(10 days, eligible) → returned.- Back in
_exec:version.isCurrent()compares"bun-v1.2.4"against"bun-v1.2.5"→false. - Bun prints
Bun v1.2.4 is out! You're on v1.2.5and proceeds to download, verify (the post-download check only confirms the binary's--versionmatches the selected tag1.2.4, not that it's>=current), and replace the running binary with v1.2.4.
This is a real, easy-to-hit regression: Bun ships releases multiple times per week, and bunfig.toml is project-local while the Bun binary is global, so "installed version is newer than what minimumReleaseAge permits" is the common case, not an edge case.
Secondary issue: prereleases
Per the GitHub REST API, GET /releases/latest returns "the most recent non-prerelease, non-draft release", but GET /releases returns all releases including those with prerelease: true. The fallback loop does not check release_expr.asProperty("prerelease"), so a prerelease whose published_at passes the window would be selected as the "stable" upgrade target — an inconsistency between the primary and fallback paths. Drafts are incidentally covered (their published_at is null, caught by orelse continue), but prereleases have a real timestamp.
On the refutation
One reviewer noted that Jarred-Sumner/bun-releases-for-updater is a curated mirror that has historically never contained prereleases (canary uses a separate hardcoded oven-sh/bun/releases/download/canary URL that doesn't go through this code). That's accurate — the prerelease concern is low practical risk today. However: (a) the downgrade issue above does not depend on prereleases existing and is fully reproducible against the real repo as it stands; (b) for the prerelease filter specifically, this PR's stated motivation is supply-chain risk reduction, and adding if (prerelease == true) continue is a one-line defensive check that makes the fallback path semantically match the primary path rather than silently relying on an external repo's publishing conventions. So the prerelease point is a defensive nit, but the downgrade point is a blocking correctness bug.
How to fix
In the while (releases.next()) loop, before returning:
- Skip when the candidate tag equals
Version.current_version(treat as "already on latest" — returnnullso the caller prints the "no eligible release" message, or have_execshort-circuit when the selected tag<=current). - Skip when the candidate tag is older than the current version (a simple semver comparison on the
bun-vX.Y.Zsuffix, or stop iterating once you pass the current tag since the list is newest-first). - Skip when
release_expr.asProperty("prerelease")istrue(and optionallydraft, though that's already covered by the nullpublished_at).
Alternatively, guard in _exec after getLatestVersion returns: if the selected version < Global.package_json_version, print the "Congrats! You're already on the latest version" message and exit 0 instead of downloading.
|
CI situation on this PR: 68 green, 5 red. The red lanes are all known-flaky infra on recent
I spent my one My diff builds cleanly on Linux x64 / aarch64 / x64-musl / aarch64-musl / x64-baseline / x64-musl-baseline / x64-asan / x64-android / aarch64-android, FreeBSD x64 / aarch64, macOS x64 / aarch64, and Windows aarch64 — everything except the one Windows x64 zig lane. All nine tests in Handing off — needs a maintainer to merge past the flaky lanes. |
|
The body of #30533 reads as thought OP meant to say |
|
Closing — @alii makes a fair point. Re-reading #30533, the reporter's phrasing ("project dependency upgrades and global package upgrades") matches what |
Closes #30533.
What
install.minimumReleaseAgeis already honored bybun installandbun update(via the npm resolver'sisPackageVersionTooRecentfilter), butbun upgrade— which replaces the Bun binary itself — ignored the setting entirely. This meant a team configuring a release-age window to reduce supply-chain risk for their dependencies was still exposed to freshly-published Bun releases when upgrading Bun itself.This teaches
bun upgradethe same filter:How
UpgradeCommandis added to the existing config-loading tables (loads_config,always_loads_config,readGlobalConfig) and to theinstall-section gate inbunfig.zigsoctx.install.minimum_release_age_msis populated by the same path used forbun update.getLatestVersionnow readspublished_atfrom the GitHub Releases API response and compares it against the configured window usingbun.jsc.wtf.parseES5Date— the same parser already used for npm'stimefield insrc/install/npm.zig.GET /releases?per_page=10and returns the newest release whosepublished_atis outside the window. If none qualifies, it refuses to upgrade with an explicit error rather than silently installing the too-recent build.github.com/.../releases/download/canaryzip URL, don't go through the JSON API, and are opted into explicitly.Tests
test/cli/install/bun-upgrade.test.tsgrows three cases, each backed by aBun.servemock of the GitHub Releases API:minimumReleaseAgeerror.Gate-checked locally: stashing the
src/changes makes cases 1 and 2 fail (picksv999.999.999/ silently downloads it); restoring them passes all 9 tests in the file.