fix(update-check): auto-install patch bumps without SPAWN_AUTO_UPDATE (#3296)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run

auto-install to same-major.minor bumps. The intent was "give users control
over feature updates" but the effect was "nobody installs security patches"
because the default became notice-only for everything.

This decouples the two ideas and aligns the policy with semver intent:

  - PATCH bumps (1.0.5 -> 1.0.7, same major.minor): auto-install always,
    no opt-in needed. Patches are reserved for bug fixes and security
    hardening. Blast radius is bounded by semver: no behavior changes,
    no new features, no breaking changes.

  - MINOR / MAJOR bumps (1.0.x -> 1.1.0, 1.x.x -> 2.0.0): respect
    SPAWN_AUTO_UPDATE=1 as opt-in. These can contain behavior changes
    and users should decide when to move to them.

  - SPAWN_NO_AUTO_UPDATE=1: new explicit opt-out for CI environments
    or pinned installs that need a fully static CLI.

Caveat — the one-time hurdle: users currently on 1.0.6 won't get 1.0.7
automatically, because they're still running 1.0.6's update-check.ts
which honors the old opt-in gate. Once they reach 1.0.7 via spawn update
(or by setting SPAWN_AUTO_UPDATE=1), every future patch will propagate
automatically and the fleet becomes self-healing on security.

Tests:
- 5 new tests lock in the policy (patch auto without env, minor notice
  without env, minor auto with env, major notice without env, explicit
  opt-out suppresses patch)
- All 21 update-check tests pass (16 existing + 5 new)
- 2109/2109 total suite

Bumps 1.0.6 -> 1.0.7.
This commit is contained in:
Ahmed Abushagur 2026-04-14 03:38:08 -07:00 committed by GitHub
parent c6287b9194
commit 655a909955
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 124 additions and 7 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "1.0.8",
"version": "1.0.9",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -468,4 +468,106 @@ describe("update-check", () => {
process.argv = originalArgv;
});
});
// ── Update policy: patch = auto, minor/major = opt-in ────────────────────
//
// These tests lock in the behavior from fix/auto-update-patches:
// - PATCH bumps (same major.minor) auto-install regardless of env vars
// - MINOR / MAJOR bumps require SPAWN_AUTO_UPDATE=1 to auto-install
// - SPAWN_NO_AUTO_UPDATE=1 suppresses auto-install entirely
describe("update policy", () => {
it("auto-installs patch bumps even without SPAWN_AUTO_UPDATE=1", async () => {
// 1.0.6 -> 1.0.99 is a patch bump (same major.minor)
process.env.SPAWN_AUTO_UPDATE = undefined;
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));
const { executor } = await import("../update-check.js");
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => Buffer.from(""));
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
const output = consoleErrorSpy.mock.calls.map((call: unknown[]) => call[0]).join("\n");
expect(output).toContain("Update available");
expect(output).toContain("Updating automatically");
expect(execFileSyncSpy).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(0);
fetchSpy.mockRestore();
execFileSyncSpy.mockRestore();
});
it("shows notice only for minor bumps without SPAWN_AUTO_UPDATE=1", async () => {
// 1.0.6 -> 1.1.0 is a minor bump
process.env.SPAWN_AUTO_UPDATE = undefined;
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.1.0\n")));
const { executor } = await import("../update-check.js");
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => Buffer.from(""));
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
const output = consoleErrorSpy.mock.calls.map((call: unknown[]) => call[0]).join("\n");
// Notice should mention the version jump
expect(output).toContain("Update available");
expect(output).toContain("1.1.0");
// Must NOT auto-install — no curl, no bash, no re-exec
expect(execFileSyncSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
execFileSyncSpy.mockRestore();
});
it("shows notice only for major bumps without SPAWN_AUTO_UPDATE=1", async () => {
// 1.0.6 -> 2.0.0 is a major bump
process.env.SPAWN_AUTO_UPDATE = undefined;
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("2.0.0\n")));
const { executor } = await import("../update-check.js");
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => Buffer.from(""));
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
expect(execFileSyncSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
execFileSyncSpy.mockRestore();
});
it("auto-installs minor bumps WITH SPAWN_AUTO_UPDATE=1", async () => {
// 1.0.6 -> 1.1.0 with opt-in env var
process.env.SPAWN_AUTO_UPDATE = "1";
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.1.0\n")));
const { executor } = await import("../update-check.js");
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => Buffer.from(""));
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
expect(execFileSyncSpy).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(0);
fetchSpy.mockRestore();
execFileSyncSpy.mockRestore();
});
it("SPAWN_NO_AUTO_UPDATE=1 suppresses patch auto-install (CI pinning)", async () => {
// Explicit opt-out — even patches should show notice only
process.env.SPAWN_AUTO_UPDATE = undefined;
process.env.SPAWN_NO_AUTO_UPDATE = "1";
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));
const { executor } = await import("../update-check.js");
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => Buffer.from(""));
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
expect(execFileSyncSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
execFileSyncSpy.mockRestore();
});
});
});

View file

@ -400,21 +400,36 @@ export async function checkForUpdates(jsonOutput = false): Promise<void> {
// Record successful check so we don't hit the network again for an hour
markUpdateChecked();
// Notify if newer version is available
// Notify (or auto-install) if a newer version is available.
if (compareVersions(VERSION, latestVersion)) {
// Only auto-update within the same major.minor (patch updates only).
// e.g. 1.0.0 → 1.0.5 is allowed, 1.0.0 → 1.1.0 is not.
// Update policy, semver-aligned:
//
// PATCH bumps (same major.minor, e.g. 1.0.5 → 1.0.7) are always
// auto-installed. Patches are reserved for bug fixes and security
// hardening — users benefit from getting them without opting in, and
// the blast radius is bounded by semver: no behavior changes, no
// breaking changes, no new features.
//
// MINOR / MAJOR bumps (e.g. 1.0.x → 1.1.0, 1.x.x → 2.0.0) respect
// SPAWN_AUTO_UPDATE=1 as opt-in. These can contain behavior changes
// and users should decide when to move to them.
//
// SPAWN_NO_AUTO_UPDATE=1 lets users opt OUT of patch-level auto-update
// entirely if they need a fully pinned CLI (CI environments, etc.).
const patchOnly = isSameMinor(VERSION, latestVersion);
const explicitOptOut = process.env.SPAWN_NO_AUTO_UPDATE === "1";
const explicitOptIn = process.env.SPAWN_AUTO_UPDATE === "1";
if (patchOnly && process.env.SPAWN_AUTO_UPDATE === "1") {
// Opt-in auto-update for patch versions
const shouldAutoInstall = !explicitOptOut && (patchOnly || explicitOptIn);
if (shouldAutoInstall) {
const r = tryCatch(() => performAutoUpdate(latestVersion, jsonOutput));
if (!r.ok) {
logWarn("Auto-update encountered an error");
logDebug(getErrorMessage(r.error));
}
} else {
// Show notice: either auto-update is off, or it's a minor/major bump
// Minor/major bump without opt-in, or explicit opt-out — show notice.
printUpdateNotice(latestVersion);
}
}