Skip to content

bun upgrade: honor install.minimumReleaseAge#30534

Closed
robobun wants to merge 3 commits into
mainfrom
farm/ca986c09/upgrade-minimum-release-age
Closed

bun upgrade: honor install.minimumReleaseAge#30534
robobun wants to merge 3 commits into
mainfrom
farm/ca986c09/upgrade-minimum-release-age

Conversation

@robobun

@robobun robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator

Closes #30533.

What

install.minimumReleaseAge is already honored by bun install and bun update (via the npm resolver's isPackageVersionTooRecent filter), but bun 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 upgrade the same filter:

[install]
minimumReleaseAge = 86400 # 1 day

How

  • UpgradeCommand is added to the existing config-loading tables (loads_config, always_loads_config, readGlobalConfig) and to the install-section gate in bunfig.zig so ctx.install.minimum_release_age_ms is populated by the same path used for bun update.
  • getLatestVersion now reads published_at from the GitHub Releases API response and compares it against the configured window using bun.jsc.wtf.parseES5Date — the same parser already used for npm's time field in src/install/npm.zig.
  • When the latest release is inside the window, it falls back to GET /releases?per_page=10 and returns the newest release whose published_at is outside the window. If none qualifies, it refuses to upgrade with an explicit error rather than silently installing the too-recent build.
  • Canary upgrades are intentionally untouched — they use a fixed github.com/.../releases/download/canary zip URL, don't go through the JSON API, and are opted into explicitly.

Tests

test/cli/install/bun-upgrade.test.ts grows three cases, each backed by a Bun.serve mock of the GitHub Releases API:

  1. Latest release is too recent → falls back to the older tag in the list endpoint.
  2. Latest release is too recent and no older release qualifies → exits non-zero with a minimumReleaseAge error.
  3. Latest release already passes the window → uses it directly; list endpoint is never hit.

Gate-checked locally: stashing the src/ changes makes cases 1 and 2 fail (picks v999.999.999 / silently downloads it); restoring them passes all 9 tests in the file.

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
@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 PM PT - May 11th, 2026

@robobun, your commit f4cb3da has 2 failures in Build #53598 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30534

That installs a local version of the PR into your bun-30534 executable, so you can run:

bun-30534 --bun

@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR adds install.minimumReleaseAge configuration support to bun upgrade --stable to prevent installation of recently released versions. It enables UpgradeCommand to load config, introduces release-parsing helpers with age-gating logic and fallback to older releases, and adds three test cases validating the behavior across different age scenarios.

Changes

Minimum Release Age Gating

Layer / File(s) Summary
Config loading for UpgradeCommand
src/cli/bunfig.zig, src/options_types/CommandTag.zig
UpgradeCommand is added to config-loading tables (loads_config, always_loads_config) and the readGlobalConfig() conditional so it reads install settings from bunfig files.
Release parsing infrastructure
src/cli/upgrade_command.zig
New ParsedRelease struct and helper functions parse GitHub release objects to extract tag_name, compute published_at epoch-milliseconds, and locate matching platform ZIP assets.
Version resolution with age-gating
src/cli/upgrade_command.zig
getLatestVersion now accepts minimum_release_age_ms parameter. When set and the latest release is too recent, the function fetches /releases?per_page=10 and selects the newest release older than the configured window using the new parsing helpers. The execution path reads ctx.install.minimum_release_age_ms and converts it to milliseconds before passing to getLatestVersion.
Test coverage for age-gating
test/cli/install/bun-upgrade.test.ts
Three new tests validate behavior when install.minimumReleaseAge is configured: fallback to older tag when /releases/latest is too recent, error exit when all candidates exceed the age window, and direct use of latest when it already satisfies the age constraint without hitting the releases list endpoint.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: teaching bun upgrade to honor the install.minimumReleaseAge setting, matching the core objective of the pull request.
Description check ✅ Passed The description fully addresses the template by explaining what the PR does and how it was verified, with comprehensive technical details, implementation approach, and test coverage.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 2043f9c and a5000dd.

📒 Files selected for processing (4)
  • src/cli/bunfig.zig
  • src/cli/upgrade_command.zig
  • src/options_types/CommandTag.zig
  • test/cli/install/bun-upgrade.test.ts

Comment on lines +372 to +374
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| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment on lines +450 to 453
switch (response.status_code) {
200 => {},
else => return null, // fall back to "no eligible release"
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment on lines +277 to +280
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`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n --type=ts -C1 '\btmpdirSync\(' test/cli/install/bun-upgrade.test.ts

Repository: 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.

Comment on lines +462 to +472

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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)

  1. User installs Bun via curl -fsSL https://bun.com/install | bash (or homebrew/npm). These installers ignore bunfig and install the latest release — say v1.2.5, published 3 days ago.
  2. User has [install] minimumReleaseAge = 604800 (7 days) in a project-local bunfig.toml for npm supply-chain safety.
  3. User runs bun upgrade from that directory.
  4. getLatestVersion fetches /releases/latestv1.2.6 (or even v1.2.5 itself), published < 7 days ago → isReleaseTooRecent is true → enters the fallback.
  5. findEligibleVersionFromReleasesList walks /releases newest-first: v1.2.6 (2 days, skip), v1.2.5 (3 days, skip), v1.2.4 (10 days, eligible) → returned.
  6. Back in _exec: version.isCurrent() compares "bun-v1.2.4" against "bun-v1.2.5"false.
  7. Bun prints Bun v1.2.4 is out! You're on v1.2.5 and proceeds to download, verify (the post-download check only confirms the binary's --version matches the selected tag 1.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" — return null so the caller prints the "no eligible release" message, or have _exec short-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.Z suffix, or stop iterating once you pass the current tag since the list is newest-first).
  • Skip when release_expr.asProperty("prerelease") is true (and optionally draft, though that's already covered by the null published_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.

@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

CI situation on this PR: 68 green, 5 red. The red lanes are all known-flaky infra on recent @robobun PRs, not anything my diff touches:

I spent my one ci: retrigger commit already — the retrigger swapped the failing lanes (last run had debian-13-x64-asan-test-bun red and these three green; this run is the inverse), which is the signature of flake, not a deterministic code issue.

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 test/cli/install/bun-upgrade.test.ts pass locally against the ASAN debug build. Gate-checked: stashing the src/ changes makes the two new install.minimumReleaseAge assertions fail and picks up v999.999.999; restoring passes.

Handing off — needs a maintainer to merge past the flaky lanes.

@alii

alii commented May 12, 2026

Copy link
Copy Markdown
Member

The body of #30533 reads as thought OP meant to say bun update rather than upgrade. It doesn't make sense necessarily to worry about versions of Bun

@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

Closing — @alii makes a fair point. Re-reading #30533, the reporter's phrasing ("project dependency upgrades and global package upgrades") matches what bun update / bun install -g already do, and those already honor minimumReleaseAge via the resolver's isPackageVersionTooRecent filter. bun upgrade is the Bun binary updater and the age-gating rationale (supply-chain risk from freshly published npm packages) doesn't really carry over to a signed Bun release we ship ourselves. Dropping this.

@robobun robobun closed this May 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bun upgrade should honor minimumReleaseAge

2 participants