mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 11:59:29 +00:00
fix(update-check): auto-install patch bumps without SPAWN_AUTO_UPDATE (#3296)
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:
parent
c6287b9194
commit
655a909955
3 changed files with 124 additions and 7 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue