mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(update): prevent stable upgrade notices on extended-stable (#118518)
* fix(update): keep extended-stable update notices on channel * fix(update): repair extended-stable CI checks * fix(update): retain verified extended-stable channel * fix(update): normalize gateway install surface * test(update): split effective channel coverage * fix(update): resolve verified extended-stable status paths * fix(update): preserve Sparkle fallback on missing channel * fix(update): restore effective channel after rebase * fix(update): repair rebased CI coverage
This commit is contained in:
@@ -495,7 +495,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
return
|
||||
case let .continueLaunch(startUpdater):
|
||||
if startUpdater {
|
||||
self.updaterController.start()
|
||||
if OpenClawConfigFile.gatewayUpdateChannel() == nil {
|
||||
self.updaterController.startAfterResolvingGatewayUpdateChannel()
|
||||
} else {
|
||||
self.updaterController.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remote startup can spawn an SSH child. Admit tunnel work only after the
|
||||
@@ -769,11 +773,16 @@ protocol UpdaterProviding: AnyObject {
|
||||
var isAvailable: Bool { get }
|
||||
var updateStatus: UpdateStatus { get }
|
||||
func start()
|
||||
func startAfterResolvingGatewayUpdateChannel()
|
||||
func checkForUpdates(_ sender: Any?)
|
||||
}
|
||||
|
||||
extension UpdaterProviding {
|
||||
func start() {}
|
||||
|
||||
func startAfterResolvingGatewayUpdateChannel() {
|
||||
self.start()
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op updater used for debug/dev runs to suppress Sparkle dialogs.
|
||||
@@ -807,8 +816,28 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding {
|
||||
userDriverDelegate: nil)
|
||||
let updateStatus = UpdateStatus()
|
||||
private var started = false
|
||||
private var gatewayUpdateChannel: String?
|
||||
private var resolvingGatewayUpdateChannel = false
|
||||
private let gatewayUpdateChannelResolver: @MainActor @Sendable () async throws -> String?
|
||||
private let onStart: (() -> Void)?
|
||||
|
||||
init(savedAutoUpdate: Bool) {
|
||||
init(
|
||||
savedAutoUpdate: Bool,
|
||||
gatewayUpdateChannelResolver: (@MainActor @Sendable () async throws -> String?)? = nil,
|
||||
onStart: (() -> Void)? = nil)
|
||||
{
|
||||
self.gatewayUpdateChannelResolver = gatewayUpdateChannelResolver ?? {
|
||||
struct UpdateStatusResponse: Decodable {
|
||||
let effectiveChannel: String?
|
||||
}
|
||||
guard let data = try? await GatewayConnection.shared.requestRaw(
|
||||
method: "update.status",
|
||||
timeoutMs: 5000),
|
||||
let response = try? JSONDecoder().decode(UpdateStatusResponse.self, from: data)
|
||||
else { return nil }
|
||||
return OpenClawConfigFile.normalizedGatewayUpdateChannel(response.effectiveChannel)
|
||||
}
|
||||
self.onStart = onStart
|
||||
super.init()
|
||||
let updater = self.controller.updater
|
||||
updater.automaticallyChecksForUpdates = savedAutoUpdate
|
||||
@@ -818,7 +847,24 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding {
|
||||
func start() {
|
||||
guard !self.started else { return }
|
||||
self.started = true
|
||||
self.controller.startUpdater()
|
||||
if let onStart = self.onStart {
|
||||
onStart()
|
||||
} else {
|
||||
self.controller.startUpdater()
|
||||
}
|
||||
}
|
||||
|
||||
func startAfterResolvingGatewayUpdateChannel() {
|
||||
guard !self.started, !self.resolvingGatewayUpdateChannel else { return }
|
||||
self.resolvingGatewayUpdateChannel = true
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
defer { self.resolvingGatewayUpdateChannel = false }
|
||||
self.gatewayUpdateChannel = try? await self.gatewayUpdateChannelResolver()
|
||||
// Older or unreachable Gateways cannot report effectiveChannel. Preserve
|
||||
// their existing stable Sparkle behavior instead of disabling updates.
|
||||
self.start()
|
||||
}
|
||||
}
|
||||
|
||||
var automaticallyChecksForUpdates: Bool {
|
||||
@@ -874,14 +920,44 @@ func allowedSparkleChannels(forGatewayUpdateChannel channel: String?) -> Set<Str
|
||||
switch channel {
|
||||
case "beta", "dev":
|
||||
["beta"]
|
||||
case "extended-stable":
|
||||
["extended-stable"]
|
||||
default:
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
func isSparkleUpdateAllowed(itemChannel: String?, forGatewayUpdateChannel channel: String?) -> Bool {
|
||||
channel != "extended-stable" || itemChannel == "extended-stable"
|
||||
}
|
||||
|
||||
extension SparkleUpdaterController: SPUUpdaterDelegate {
|
||||
func allowedChannels(for _: SPUUpdater) -> Set<String> {
|
||||
allowedSparkleChannels(forGatewayUpdateChannel: OpenClawConfigFile.gatewayUpdateChannel())
|
||||
allowedSparkleChannels(
|
||||
forGatewayUpdateChannel: self.gatewayUpdateChannel ?? OpenClawConfigFile.gatewayUpdateChannel())
|
||||
}
|
||||
|
||||
func bestValidUpdate(in appcast: SUAppcast, for _: SPUUpdater) -> SUAppcastItem? {
|
||||
guard self.gatewayUpdateChannel ?? OpenClawConfigFile.gatewayUpdateChannel() == "extended-stable" else {
|
||||
return nil
|
||||
}
|
||||
let comparator = SUStandardVersionComparator.default
|
||||
let currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
|
||||
// Sparkle always admits the default channel. Filter it here so an
|
||||
// extended-stable Gateway is never prompted to leave its release train.
|
||||
let eligibleItems = appcast.items.filter {
|
||||
guard isSparkleUpdateAllowed(
|
||||
itemChannel: $0.channel,
|
||||
forGatewayUpdateChannel: "extended-stable")
|
||||
else { return false }
|
||||
guard let currentVersion else { return true }
|
||||
return comparator.compareVersion(
|
||||
$0.versionString,
|
||||
toVersion: currentVersion) == .orderedDescending
|
||||
}
|
||||
return eligibleItems.max { left, right in
|
||||
comparator.compareVersion(left.versionString, toVersion: right.versionString) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
func updater(_: SPUUpdater, willInstallUpdate item: SUAppcastItem) {
|
||||
|
||||
@@ -313,9 +313,14 @@ struct UpdateOrchestrationTests {
|
||||
OpenClawConfigFile.normalizedGatewayUpdateChannel(" BETA \n")) == ["beta"])
|
||||
#expect(OpenClawConfigFile.normalizedGatewayUpdateChannel(" \n") == nil)
|
||||
#expect(allowedSparkleChannels(forGatewayUpdateChannel: "stable").isEmpty)
|
||||
#expect(allowedSparkleChannels(forGatewayUpdateChannel: "extended-stable").isEmpty)
|
||||
#expect(allowedSparkleChannels(forGatewayUpdateChannel: "extended-stable") == ["extended-stable"])
|
||||
#expect(allowedSparkleChannels(forGatewayUpdateChannel: "future").isEmpty)
|
||||
#expect(allowedSparkleChannels(forGatewayUpdateChannel: nil).isEmpty)
|
||||
#expect(isSparkleUpdateAllowed(itemChannel: nil, forGatewayUpdateChannel: "stable"))
|
||||
#expect(!isSparkleUpdateAllowed(itemChannel: nil, forGatewayUpdateChannel: "extended-stable"))
|
||||
#expect(isSparkleUpdateAllowed(
|
||||
itemChannel: "extended-stable",
|
||||
forGatewayUpdateChannel: "extended-stable"))
|
||||
}
|
||||
|
||||
#if canImport(Sparkle)
|
||||
@@ -326,6 +331,38 @@ struct UpdateOrchestrationTests {
|
||||
updater.checkForUpdates(nil)
|
||||
#expect(!updater.isAvailable)
|
||||
}
|
||||
|
||||
@Test func `Sparkle keeps legacy stable fallback when Gateway omits update channel`() async {
|
||||
var starts = 0
|
||||
let updater = SparkleUpdaterController(
|
||||
savedAutoUpdate: false,
|
||||
gatewayUpdateChannelResolver: { nil },
|
||||
onStart: { starts += 1 })
|
||||
|
||||
updater.startAfterResolvingGatewayUpdateChannel()
|
||||
for _ in 0 ..< 10 where !updater.isAvailable {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
#expect(updater.isAvailable)
|
||||
#expect(starts == 1)
|
||||
}
|
||||
|
||||
@Test func `Sparkle keeps legacy stable fallback when Gateway channel lookup fails`() async {
|
||||
var starts = 0
|
||||
let updater = SparkleUpdaterController(
|
||||
savedAutoUpdate: false,
|
||||
gatewayUpdateChannelResolver: { throw CancellationError() },
|
||||
onStart: { starts += 1 })
|
||||
|
||||
updater.startAfterResolvingGatewayUpdateChannel()
|
||||
for _ in 0 ..< 10 where !updater.isAvailable {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
#expect(updater.isAvailable)
|
||||
#expect(starts == 1)
|
||||
}
|
||||
#endif
|
||||
|
||||
@Test func `dashboard accepts only start update payloads`() {
|
||||
|
||||
@@ -18070,21 +18070,25 @@ public struct UpdateStatusParams: Codable, Sendable {}
|
||||
public struct UpdateStatusResult: Codable, Sendable {
|
||||
public let sentinel: AnyCodable
|
||||
public let updateavailable: AnyCodable
|
||||
public let effectivechannel: AnyCodable?
|
||||
public let schedule: UpdateScheduleState?
|
||||
|
||||
public init(
|
||||
sentinel: AnyCodable,
|
||||
updateavailable: AnyCodable,
|
||||
effectivechannel: AnyCodable? = nil,
|
||||
schedule: UpdateScheduleState? = nil)
|
||||
{
|
||||
self.sentinel = sentinel
|
||||
self.updateavailable = updateavailable
|
||||
self.effectivechannel = effectivechannel
|
||||
self.schedule = schedule
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sentinel
|
||||
case updateavailable = "updateAvailable"
|
||||
case effectivechannel = "effectiveChannel"
|
||||
case schedule
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@ OpenClaw ships four update channels:
|
||||
- **stable**: npm dist-tag `latest`. Recommended for most users.
|
||||
- **extended-stable**: npm dist-tag `extended-stable`. A net-new, trailing
|
||||
supported-month package channel. It is package-only, and installation is
|
||||
foreground-only. A stored selection receives read-only update hints when
|
||||
`update.checkOnStart` is enabled, but never applies automatically.
|
||||
foreground-only. It receives read-only update hints when `update.checkOnStart`
|
||||
is enabled, including direct final extended-stable package installs, but never
|
||||
applies automatically.
|
||||
- **beta**: npm dist-tag `beta`. Falls back to `latest` when `beta` is missing
|
||||
or older than the current stable release.
|
||||
- **dev**: moving head of `main` (git). npm dist-tag `dev` when published. `main`
|
||||
|
||||
@@ -46,7 +46,8 @@ or inconsistent registry data fails closed; it never falls back to `latest`.
|
||||
If the selected version is older than the installed version, the normal
|
||||
downgrade confirmation still applies. The CLI persists the channel after a
|
||||
successful core update; a direct `npm install -g openclaw@extended-stable`
|
||||
does not update `update.channel`.
|
||||
does not update `update.channel`, but a final extended-stable package version
|
||||
still checks only the verified `extended-stable` selector for update availability.
|
||||
After the core swap, eligible official npm plugins with bare/default or
|
||||
`latest` intent converge to that exact core version. Exact pins and explicit
|
||||
non-`latest` tags, third-party plugins, and non-npm sources remain unchanged.
|
||||
|
||||
@@ -78,8 +78,9 @@ updates only the local Mac node runtime and skips the notification when the
|
||||
remote Gateway is older than the app.
|
||||
|
||||
Sparkle follows the Gateway's `update.channel` setting. `beta` and `dev` opt in
|
||||
to beta app builds; `stable`, `extended-stable`, and missing or unknown values
|
||||
stay on stable app builds.
|
||||
to beta app builds; `extended-stable` accepts only extended-stable app releases,
|
||||
so it stays quiet when no matching app release exists. `stable`, missing, and
|
||||
unknown values stay on stable app builds.
|
||||
|
||||
## Open dashboard links
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ describe("update protocol schemas", () => {
|
||||
{ sha: "def5678", subject: "Second change" },
|
||||
],
|
||||
},
|
||||
effectiveChannel: "dev",
|
||||
schedule: {
|
||||
channel: "dev",
|
||||
autoEnabled: true,
|
||||
|
||||
@@ -162,6 +162,14 @@ export const UpdateScheduleStateSchema = closedObject({
|
||||
export const UpdateStatusResultSchema = closedObject({
|
||||
sentinel: Type.Unknown(),
|
||||
updateAvailable: Type.Union([UpdateAvailableSchema, Type.Null()]),
|
||||
effectiveChannel: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("stable"),
|
||||
Type.Literal("extended-stable"),
|
||||
Type.Literal("beta"),
|
||||
Type.Literal("dev"),
|
||||
]),
|
||||
),
|
||||
schedule: Type.Optional(UpdateScheduleStateSchema),
|
||||
});
|
||||
|
||||
|
||||
@@ -68,6 +68,8 @@ if [[ "$VERSION" == *-alpha.* || "$VERSION" == *.alpha.* ]]; then
|
||||
fi
|
||||
if [[ "$VERSION" == *-beta.* || "$VERSION" == *.beta.* ]]; then
|
||||
CHANNEL_ARGS=(--channel beta)
|
||||
elif [[ "$VERSION" =~ ^[0-9]{4}\.[1-9][0-9]*\.([0-9]+)$ && "${BASH_REMATCH[1]}" -ge 33 ]]; then
|
||||
CHANNEL_ARGS=(--channel extended-stable)
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
|
||||
@@ -4,12 +4,12 @@ import { theme } from "../../../packages/terminal-core/src/theme.js";
|
||||
import {
|
||||
formatUpdateAvailableHint,
|
||||
formatUpdateOneLiner,
|
||||
resolveStatusRegistryUpdateChannel,
|
||||
resolveUpdateAvailability,
|
||||
} from "../../commands/status.update.js";
|
||||
import { readSourceConfigBestEffort } from "../../config/config.js";
|
||||
import {
|
||||
normalizeUpdateChannel,
|
||||
resolveRegistryUpdateChannel,
|
||||
resolveUpdateChannelDisplay,
|
||||
} from "../../infra/update-channels.js";
|
||||
import { checkUpdateStatus } from "../../infra/update-check.js";
|
||||
@@ -49,10 +49,12 @@ export async function updateStatusCommand(opts: UpdateStatusOptions): Promise<vo
|
||||
timeoutMs: timeoutMs ?? 3500,
|
||||
fetchGit: true,
|
||||
includeRegistry: true,
|
||||
registryChannel: resolveRegistryUpdateChannel({
|
||||
configChannel,
|
||||
currentVersion: VERSION,
|
||||
}),
|
||||
resolveRegistryChannel: ({ installKind, git }) =>
|
||||
resolveStatusRegistryUpdateChannel({
|
||||
configChannel,
|
||||
installKind,
|
||||
git,
|
||||
}),
|
||||
});
|
||||
|
||||
const channelInfo = resolveUpdateChannelDisplay({
|
||||
|
||||
@@ -15,9 +15,9 @@ import {
|
||||
import {
|
||||
channelToNpmTag,
|
||||
DEFAULT_GIT_CHANNEL,
|
||||
DEFAULT_PACKAGE_CHANNEL,
|
||||
EXTENDED_STABLE_TAG_UNSUPPORTED_REASON,
|
||||
normalizeUpdateChannel,
|
||||
resolveEffectiveUpdateChannel,
|
||||
} from "../../infra/update-channels.js";
|
||||
import { fetchNpmPackageTargetStatus } from "../../infra/update-check-package-target.js";
|
||||
import {
|
||||
@@ -38,6 +38,7 @@ import { cleanupStaleManagedServiceUpdateHandoffs } from "../../infra/update-man
|
||||
import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { OpenClawSchemaVersions } from "../../state/openclaw-schema-versions.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
import { resolveCliName } from "../cli-name.js";
|
||||
import { createUpdateProgress } from "./progress.js";
|
||||
import {
|
||||
@@ -206,7 +207,12 @@ async function updateCommandInternal(
|
||||
const selectedChannel =
|
||||
requestedChannel ??
|
||||
storedChannel ??
|
||||
(installKind === "git" ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL);
|
||||
(installKind === "git"
|
||||
? DEFAULT_GIT_CHANNEL
|
||||
: resolveEffectiveUpdateChannel({
|
||||
currentVersion: VERSION,
|
||||
installKind,
|
||||
}).channel);
|
||||
if (selectedChannel === "extended-stable" && installKind === "git") {
|
||||
await reportPreMutationUpdateFailure({
|
||||
root,
|
||||
@@ -221,9 +227,15 @@ async function updateCommandInternal(
|
||||
const switchToPackage =
|
||||
requestedChannel !== null && requestedChannel !== "dev" && installKind === "git";
|
||||
const updateInstallKind = switchToGit ? "git" : switchToPackage ? "package" : installKind;
|
||||
const defaultChannel =
|
||||
updateInstallKind === "git" ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL;
|
||||
const channel = requestedChannel ?? storedChannel ?? defaultChannel;
|
||||
const channel =
|
||||
requestedChannel ??
|
||||
storedChannel ??
|
||||
(updateInstallKind === "git"
|
||||
? DEFAULT_GIT_CHANNEL
|
||||
: resolveEffectiveUpdateChannel({
|
||||
currentVersion: VERSION,
|
||||
installKind: updateInstallKind,
|
||||
}).channel);
|
||||
const devTargetRef =
|
||||
channel === "dev" ? process.env.OPENCLAW_UPDATE_DEV_TARGET_REF?.trim() || undefined : undefined;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { checkUpdateStatus } from "../../infra/update-check.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { pathExists } from "../../utils.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
import {
|
||||
isEmptyDir,
|
||||
isGitCheckout,
|
||||
@@ -54,6 +55,7 @@ export async function updateWizardCommand(opts: UpdateWizardOptions = {}): Promi
|
||||
: null;
|
||||
const channelInfo = resolveEffectiveUpdateChannel({
|
||||
configChannel,
|
||||
currentVersion: VERSION,
|
||||
installKind: updateStatus.installKind,
|
||||
git: updateStatus.git
|
||||
? { tag: updateStatus.git.tag, branch: updateStatus.git.branch }
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const versionMock = vi.hoisted(() => ({ value: "2026.6.33" }));
|
||||
|
||||
vi.mock("../version.js", () => ({
|
||||
get VERSION() {
|
||||
return versionMock.value;
|
||||
},
|
||||
}));
|
||||
|
||||
const { resolveStatusRegistryUpdateChannel } = await import("./status.update.js");
|
||||
|
||||
describe("resolveStatusRegistryUpdateChannel", () => {
|
||||
it("uses extended-stable only for a verified package install", () => {
|
||||
expect(
|
||||
resolveStatusRegistryUpdateChannel({
|
||||
installKind: "package",
|
||||
}),
|
||||
).toBe("extended-stable");
|
||||
expect(
|
||||
resolveStatusRegistryUpdateChannel({
|
||||
installKind: "git",
|
||||
git: {
|
||||
root: "/tmp/openclaw",
|
||||
sha: null,
|
||||
tag: null,
|
||||
branch: "main",
|
||||
upstream: "origin/main",
|
||||
dirty: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
fetchOk: true,
|
||||
},
|
||||
}),
|
||||
).toBe("dev");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,11 @@
|
||||
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
|
||||
import { normalizeUpdateChannel, resolveRegistryUpdateChannel } from "../infra/update-channels.js";
|
||||
import {
|
||||
normalizeUpdateChannel,
|
||||
resolveEffectiveUpdateChannel,
|
||||
type UpdateChannel,
|
||||
} from "../infra/update-channels.js";
|
||||
import {
|
||||
checkUpdateStatus,
|
||||
compareSemverStrings,
|
||||
@@ -11,6 +15,20 @@ import {
|
||||
} from "../infra/update-check.js";
|
||||
import { VERSION } from "../version.js";
|
||||
|
||||
/** Chooses a registry tag only after the status check has identified the install. */
|
||||
export function resolveStatusRegistryUpdateChannel(params: {
|
||||
configChannel?: UpdateChannel | null;
|
||||
installKind: UpdateCheckResult["installKind"];
|
||||
git?: UpdateCheckResult["git"];
|
||||
}): UpdateChannel {
|
||||
return resolveEffectiveUpdateChannel({
|
||||
configChannel: params.configChannel,
|
||||
currentVersion: VERSION,
|
||||
installKind: params.installKind,
|
||||
git: params.git,
|
||||
}).channel;
|
||||
}
|
||||
|
||||
/** Runs the update check using the configured update channel and current install root. */
|
||||
export async function getUpdateCheckResult(params: {
|
||||
timeoutMs: number;
|
||||
@@ -29,10 +47,12 @@ export async function getUpdateCheckResult(params: {
|
||||
timeoutMs: params.timeoutMs,
|
||||
fetchGit: params.fetchGit,
|
||||
includeRegistry: params.includeRegistry,
|
||||
registryChannel: resolveRegistryUpdateChannel({
|
||||
configChannel,
|
||||
currentVersion: VERSION,
|
||||
}),
|
||||
resolveRegistryChannel: ({ installKind, git }) =>
|
||||
resolveStatusRegistryUpdateChannel({
|
||||
configChannel,
|
||||
installKind,
|
||||
git,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type TestUpdateAvailable = {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
channel: string;
|
||||
} | null;
|
||||
type TestUpdateSentinel = {
|
||||
kind: string;
|
||||
status: string;
|
||||
ts: number;
|
||||
stats: Record<string, unknown>;
|
||||
} | null;
|
||||
type TestUpdateSchedule =
|
||||
| import("../../../packages/gateway-protocol/src/index.js").UpdateScheduleState
|
||||
| null;
|
||||
|
||||
const checkUpdateStatusMock = vi.hoisted(() => vi.fn());
|
||||
const versionMock = vi.hoisted(() => ({ value: "1.0.0" }));
|
||||
const getUpdateAvailableMock = vi.hoisted(() => vi.fn<() => TestUpdateAvailable>(() => null));
|
||||
const getUpdateScheduleMock = vi.hoisted(() => vi.fn<() => TestUpdateSchedule>(() => null));
|
||||
const refreshGatewayUpdateStatusMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const getLatestUpdateRestartSentinelMock = vi.hoisted(() =>
|
||||
vi.fn<() => TestUpdateSentinel>(() => null),
|
||||
);
|
||||
const refreshLatestUpdateRestartSentinelMock = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<TestUpdateSentinel>>(async () => null),
|
||||
);
|
||||
|
||||
vi.mock("../../infra/openclaw-root.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../infra/openclaw-root.js")>(
|
||||
"../../infra/openclaw-root.js",
|
||||
);
|
||||
return { ...actual, resolveOpenClawPackageRoot: async () => "/tmp/openclaw" };
|
||||
});
|
||||
|
||||
vi.mock("../../infra/update-check.js", () => ({
|
||||
checkUpdateStatus: checkUpdateStatusMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/update-startup.js", () => ({
|
||||
getUpdateAvailable: getUpdateAvailableMock,
|
||||
getUpdateSchedule: getUpdateScheduleMock,
|
||||
refreshGatewayUpdateStatus: refreshGatewayUpdateStatusMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../version.js", () => ({
|
||||
get VERSION() {
|
||||
return versionMock.value;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../server-restart-sentinel.js", () => ({
|
||||
getLatestUpdateRestartSentinel: getLatestUpdateRestartSentinelMock,
|
||||
refreshLatestUpdateRestartSentinel: refreshLatestUpdateRestartSentinelMock,
|
||||
}));
|
||||
|
||||
vi.mock("./validation.js", () => ({
|
||||
assertValidParams: () => true,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
versionMock.value = "1.0.0";
|
||||
checkUpdateStatusMock.mockReset();
|
||||
getUpdateAvailableMock.mockReset();
|
||||
getUpdateAvailableMock.mockReturnValue(null);
|
||||
getUpdateScheduleMock.mockReset();
|
||||
getUpdateScheduleMock.mockReturnValue(null);
|
||||
refreshGatewayUpdateStatusMock.mockReset();
|
||||
refreshGatewayUpdateStatusMock.mockResolvedValue(undefined);
|
||||
getLatestUpdateRestartSentinelMock.mockReset();
|
||||
getLatestUpdateRestartSentinelMock.mockReturnValue(null);
|
||||
refreshLatestUpdateRestartSentinelMock.mockReset();
|
||||
refreshLatestUpdateRestartSentinelMock.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("update.status effective channel", () => {
|
||||
it("reports a verified configless extended-stable package channel", async () => {
|
||||
versionMock.value = "2026.6.33";
|
||||
checkUpdateStatusMock.mockResolvedValueOnce({
|
||||
root: "/tmp/openclaw",
|
||||
installKind: "package",
|
||||
packageManager: "npm",
|
||||
});
|
||||
const { updateHandlers } = await import("./update.js");
|
||||
const respond = vi.fn();
|
||||
|
||||
const handler = updateHandlers["update.status"];
|
||||
if (!handler) {
|
||||
throw new Error("update.status handler is unavailable");
|
||||
}
|
||||
await handler({
|
||||
params: {},
|
||||
respond,
|
||||
context: { getRuntimeConfig: () => ({ update: {} }) },
|
||||
} as never);
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ effectiveChannel: "extended-stable" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes the latest update sentinel before responding", async () => {
|
||||
getUpdateAvailableMock.mockReturnValueOnce({
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: "2.0.0",
|
||||
channel: "latest",
|
||||
});
|
||||
getLatestUpdateRestartSentinelMock.mockReturnValueOnce({
|
||||
kind: "update",
|
||||
status: "skipped",
|
||||
ts: 1,
|
||||
stats: { reason: "restart-health-pending" },
|
||||
});
|
||||
refreshLatestUpdateRestartSentinelMock.mockResolvedValueOnce({
|
||||
kind: "update",
|
||||
status: "ok",
|
||||
ts: 2,
|
||||
stats: { after: { version: "2.0.0" } },
|
||||
});
|
||||
getUpdateScheduleMock.mockReturnValueOnce({ channel: "beta", autoEnabled: true });
|
||||
const { updateHandlers } = await import("./update.js");
|
||||
const respond = vi.fn();
|
||||
|
||||
const handler = updateHandlers["update.status"];
|
||||
if (!handler) {
|
||||
throw new Error("update.status handler is unavailable");
|
||||
}
|
||||
await handler({ params: {}, respond } as never);
|
||||
|
||||
expect(refreshLatestUpdateRestartSentinelMock).toHaveBeenCalledTimes(1);
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({
|
||||
sentinel: expect.objectContaining({ kind: "update", status: "ok" }),
|
||||
updateAvailable: expect.objectContaining({ latestVersion: "2.0.0" }),
|
||||
schedule: expect.objectContaining({ channel: "beta" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the cached update sentinel when refresh fails", async () => {
|
||||
refreshLatestUpdateRestartSentinelMock.mockRejectedValueOnce(new Error("read failed"));
|
||||
getLatestUpdateRestartSentinelMock.mockReturnValueOnce({
|
||||
kind: "update",
|
||||
status: "skipped",
|
||||
ts: 1,
|
||||
stats: { reason: "restart-health-pending" },
|
||||
});
|
||||
const warn = vi.fn();
|
||||
const { updateHandlers } = await import("./update.js");
|
||||
const respond = vi.fn();
|
||||
|
||||
const handler = updateHandlers["update.status"];
|
||||
if (!handler) {
|
||||
throw new Error("update.status handler is unavailable");
|
||||
}
|
||||
await handler({ params: {}, respond, context: { logGateway: { warn } } } as never);
|
||||
|
||||
expect(warn).toHaveBeenCalledWith("update.status sentinel refresh failed: read failed");
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ sentinel: expect.objectContaining({ status: "skipped" }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { withEnvAsync } from "../../test-utils/env.js";
|
||||
let currentCampaignId: string | undefined;
|
||||
let updateSchedule: UpdateScheduleState | null;
|
||||
let updateChannel: "stable" | "beta" | "dev" | null;
|
||||
const versionMock = vi.hoisted(() => ({ value: "1.0.0" }));
|
||||
type UpdateCampaignAdoption = NonNullable<ReturnType<UpdateCampaignController["adopt"]>>;
|
||||
|
||||
const adoptCampaignMock = vi.fn<() => UpdateCampaignAdoption | undefined>(() => ({
|
||||
@@ -103,9 +104,12 @@ vi.mock("../../infra/update-campaign.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/update-channels.js", () => ({
|
||||
normalizeUpdateChannel: () => updateChannel,
|
||||
}));
|
||||
vi.mock("../../infra/update-channels.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../infra/update-channels.js")>(
|
||||
"../../infra/update-channels.js",
|
||||
);
|
||||
return { ...actual, normalizeUpdateChannel: () => updateChannel };
|
||||
});
|
||||
|
||||
vi.mock("../../infra/update-managed-service-handoff.js", () => ({
|
||||
buildManagedServiceHandoffUnavailableMessage: () => "handoff unavailable",
|
||||
@@ -131,6 +135,12 @@ vi.mock("../../infra/update-startup.js", () => ({
|
||||
getUpdateSchedule: () => updateSchedule,
|
||||
}));
|
||||
|
||||
vi.mock("../../version.js", () => ({
|
||||
get VERSION() {
|
||||
return versionMock.value;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../server-restart-sentinel.js", () => ({
|
||||
getLatestUpdateRestartSentinel: () => null,
|
||||
recordLatestUpdateRestartSentinel: () => undefined,
|
||||
@@ -157,6 +167,7 @@ beforeEach(() => {
|
||||
currentCampaignId = "campaign-1";
|
||||
updateSchedule = null;
|
||||
updateChannel = null;
|
||||
versionMock.value = "1.0.0";
|
||||
adoptCampaignMock.mockReset();
|
||||
adoptCampaignMock.mockReturnValue({
|
||||
campaignId: "campaign-1",
|
||||
@@ -269,6 +280,23 @@ describe("update.run campaign ownership", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a configless extended-stable package install on that channel", async () => {
|
||||
versionMock.value = "2026.6.33";
|
||||
detectRespawnSupervisorMock.mockReturnValueOnce("launchd");
|
||||
resolveUpdateInstallSurfaceMock.mockResolvedValueOnce({
|
||||
kind: "global",
|
||||
mode: "npm",
|
||||
root: "/tmp/openclaw",
|
||||
packageRoot: "/tmp/openclaw",
|
||||
});
|
||||
|
||||
await withEnvAsync({ OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway" }, invokeUpdateRun);
|
||||
|
||||
expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channel: "extended-stable" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a plain package update on the moving configured channel", async () => {
|
||||
updateChannel = "beta";
|
||||
adoptCampaignMock.mockReturnValueOnce(undefined);
|
||||
|
||||
@@ -31,6 +31,8 @@ const refreshLatestUpdateRestartSentinelMock = vi.fn<() => Promise<RestartSentin
|
||||
const recordLatestUpdateRestartSentinelMock = vi.fn();
|
||||
const isRestartEnabledMock = vi.fn(() => true);
|
||||
const readPackageVersionMock = vi.fn(async () => "1.0.0");
|
||||
const checkUpdateStatusMock = vi.fn();
|
||||
const versionMock = vi.hoisted(() => ({ value: "1.0.0" }));
|
||||
const detectRespawnSupervisorMock = vi.fn<() => RespawnSupervisor | null>(() => null);
|
||||
const normalizeUpdateChannelMock = vi.fn((): UpdateChannel | null => null);
|
||||
const getUpdateAvailableMock = vi.fn(
|
||||
@@ -149,13 +151,26 @@ vi.mock("../../infra/package-json.js", () => ({
|
||||
readPackageVersion: readPackageVersionMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/update-check.js", () => ({
|
||||
checkUpdateStatus: checkUpdateStatusMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../version.js", () => ({
|
||||
get VERSION() {
|
||||
return versionMock.value;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/supervisor-markers.js", () => ({
|
||||
detectRespawnSupervisor: detectRespawnSupervisorMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/update-channels.js", () => ({
|
||||
normalizeUpdateChannel: normalizeUpdateChannelMock,
|
||||
}));
|
||||
vi.mock("../../infra/update-channels.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../infra/update-channels.js")>(
|
||||
"../../infra/update-channels.js",
|
||||
);
|
||||
return { ...actual, normalizeUpdateChannel: normalizeUpdateChannelMock };
|
||||
});
|
||||
|
||||
vi.mock("../../infra/update-startup.js", () => ({
|
||||
getUpdateAvailable: getUpdateAvailableMock,
|
||||
@@ -235,6 +250,7 @@ beforeEach(() => {
|
||||
isRestartEnabledMock.mockReturnValue(true);
|
||||
readPackageVersionMock.mockClear();
|
||||
readPackageVersionMock.mockResolvedValue("1.0.0");
|
||||
versionMock.value = "1.0.0";
|
||||
normalizeUpdateChannelMock.mockReset();
|
||||
normalizeUpdateChannelMock.mockReturnValue(null);
|
||||
getUpdateAvailableMock.mockReset();
|
||||
@@ -1037,94 +1053,3 @@ describe("update.run post-core plugin finalize", () => {
|
||||
expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update.status", () => {
|
||||
it("refreshes the latest update sentinel before responding", async () => {
|
||||
getUpdateAvailableMock.mockReturnValueOnce({
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: "2.0.0",
|
||||
channel: "latest",
|
||||
});
|
||||
getLatestUpdateRestartSentinelMock.mockReturnValueOnce({
|
||||
kind: "update",
|
||||
status: "skipped",
|
||||
ts: 1,
|
||||
stats: {
|
||||
reason: "restart-health-pending",
|
||||
},
|
||||
});
|
||||
refreshLatestUpdateRestartSentinelMock.mockResolvedValueOnce({
|
||||
kind: "update",
|
||||
status: "ok",
|
||||
ts: 2,
|
||||
stats: {
|
||||
after: { version: "2.0.0" },
|
||||
},
|
||||
});
|
||||
getUpdateScheduleMock.mockReturnValueOnce({
|
||||
channel: "beta",
|
||||
autoEnabled: true,
|
||||
});
|
||||
const { updateHandlers } = await import("./update.js");
|
||||
const respond = vi.fn();
|
||||
|
||||
await expectDefined(
|
||||
updateHandlers["update.status"],
|
||||
'updateHandlers["update.status"] test invariant',
|
||||
)({
|
||||
params: {},
|
||||
respond,
|
||||
} as never);
|
||||
|
||||
expect(respond).toHaveBeenCalledTimes(1);
|
||||
const [ok, response] = firstMockCall(respond, "update status response") as [
|
||||
boolean,
|
||||
(
|
||||
| {
|
||||
sentinel?: { kind?: string; status?: string };
|
||||
updateAvailable?: { latestVersion?: string } | null;
|
||||
schedule?: { channel?: string };
|
||||
}
|
||||
| undefined
|
||||
),
|
||||
];
|
||||
expect(ok).toBe(true);
|
||||
expect(refreshLatestUpdateRestartSentinelMock).toHaveBeenCalledTimes(1);
|
||||
expect(response?.sentinel?.kind).toBe("update");
|
||||
expect(response?.sentinel?.status).toBe("ok");
|
||||
expect(response?.updateAvailable?.latestVersion).toBe("2.0.0");
|
||||
expect(response?.schedule?.channel).toBe("beta");
|
||||
});
|
||||
|
||||
it("falls back to the cached update sentinel when refresh fails", async () => {
|
||||
refreshLatestUpdateRestartSentinelMock.mockRejectedValueOnce(new Error("read failed"));
|
||||
getLatestUpdateRestartSentinelMock.mockReturnValueOnce({
|
||||
kind: "update",
|
||||
status: "skipped",
|
||||
ts: 1,
|
||||
stats: {
|
||||
reason: "restart-health-pending",
|
||||
},
|
||||
});
|
||||
const warn = vi.fn();
|
||||
const { updateHandlers } = await import("./update.js");
|
||||
const respond = vi.fn();
|
||||
|
||||
await expectDefined(
|
||||
updateHandlers["update.status"],
|
||||
'updateHandlers["update.status"] test invariant',
|
||||
)({
|
||||
params: {},
|
||||
respond,
|
||||
context: { logGateway: { warn } },
|
||||
} as never);
|
||||
|
||||
expect(warn).toHaveBeenCalledWith("update.status sentinel refresh failed: read failed");
|
||||
const [, response] = firstMockCall(respond, "update status response") as [
|
||||
boolean,
|
||||
{ sentinel?: { kind?: string; status?: string } } | undefined,
|
||||
];
|
||||
expect(response?.sentinel?.kind).toBe("update");
|
||||
expect(response?.sentinel?.status).toBe("skipped");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,11 @@ import {
|
||||
} from "../../infra/restart.js";
|
||||
import { detectRespawnSupervisor } from "../../infra/supervisor-markers.js";
|
||||
import { gatewayUpdateCampaign } from "../../infra/update-campaign.js";
|
||||
import { normalizeUpdateChannel } from "../../infra/update-channels.js";
|
||||
import {
|
||||
normalizeUpdateChannel,
|
||||
resolveEffectiveUpdateChannel,
|
||||
} from "../../infra/update-channels.js";
|
||||
import { checkUpdateStatus } from "../../infra/update-check.js";
|
||||
import { CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON } from "../../infra/update-control-plane-sentinel.js";
|
||||
import {
|
||||
buildManagedServiceHandoffUnavailableMessage,
|
||||
@@ -51,6 +55,7 @@ import {
|
||||
getUpdateSchedule,
|
||||
refreshGatewayUpdateStatus,
|
||||
} from "../../infra/update-startup.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
import { formatControlPlaneActor, resolveControlPlaneActor } from "../control-plane-audit.js";
|
||||
import {
|
||||
getLatestUpdateRestartSentinel,
|
||||
@@ -79,6 +84,32 @@ function tryResolveProcessCwd(): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveGatewayEffectiveUpdateChannel(
|
||||
configChannel: ReturnType<typeof normalizeUpdateChannel>,
|
||||
) {
|
||||
const invocationCwd = tryResolveProcessCwd();
|
||||
const root = await resolveOpenClawPackageRoot({
|
||||
moduleUrl: import.meta.url,
|
||||
argv1: process.argv[1],
|
||||
...(invocationCwd ? { cwd: invocationCwd } : {}),
|
||||
});
|
||||
const status = await checkUpdateStatus({
|
||||
root,
|
||||
timeoutMs: 2500,
|
||||
fetchGit: false,
|
||||
includeRegistry: false,
|
||||
});
|
||||
if (status.installKind === "unknown") {
|
||||
return null;
|
||||
}
|
||||
return resolveEffectiveUpdateChannel({
|
||||
configChannel,
|
||||
currentVersion: VERSION,
|
||||
installKind: status.installKind,
|
||||
git: status.git,
|
||||
}).channel;
|
||||
}
|
||||
|
||||
async function readPreUpdateConfigForPostCoreFinalize(): Promise<
|
||||
PreUpdateConfigRestoreInput | undefined
|
||||
> {
|
||||
@@ -149,6 +180,9 @@ export const updateHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
sentinel = getLatestUpdateRestartSentinel();
|
||||
}
|
||||
const configChannel = context?.getRuntimeConfig
|
||||
? normalizeUpdateChannel(context.getRuntimeConfig().update?.channel)
|
||||
: null;
|
||||
if (context?.getRuntimeConfig) {
|
||||
try {
|
||||
await refreshGatewayUpdateStatus(context.getRuntimeConfig());
|
||||
@@ -159,9 +193,13 @@ export const updateHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
}
|
||||
const schedule = getUpdateSchedule();
|
||||
const effectiveChannel = await resolveGatewayEffectiveUpdateChannel(configChannel).catch(
|
||||
() => null,
|
||||
);
|
||||
const result = {
|
||||
sentinel,
|
||||
updateAvailable: getUpdateAvailable(),
|
||||
...(effectiveChannel ? { effectiveChannel } : {}),
|
||||
...(schedule ? { schedule } : {}),
|
||||
};
|
||||
if (!validateUpdateStatusResult(result)) {
|
||||
@@ -280,6 +318,16 @@ export const updateHandlers: GatewayRequestHandlers = {
|
||||
cwd: root,
|
||||
argv1: process.argv[1],
|
||||
});
|
||||
const effectiveChannel = resolveEffectiveUpdateChannel({
|
||||
configChannel,
|
||||
currentVersion: VERSION,
|
||||
installKind:
|
||||
installSurface.kind === "git"
|
||||
? "git"
|
||||
: installSurface.kind === "global" || installSurface.kind === "package-root"
|
||||
? "package"
|
||||
: "unknown",
|
||||
}).channel;
|
||||
const supervisor = detectRespawnSupervisor(process.env, process.platform);
|
||||
const hasHandoffContext = supervisor
|
||||
? hasManagedServiceHandoffContext(process.env, supervisor)
|
||||
@@ -326,7 +374,11 @@ export const updateHandlers: GatewayRequestHandlers = {
|
||||
};
|
||||
} else if (requiresManagedServiceHandoff) {
|
||||
const handoffChannel =
|
||||
installSurface.kind === "git" ? undefined : (configChannel ?? undefined);
|
||||
installSurface.kind === "git"
|
||||
? undefined
|
||||
: effectiveChannel === "extended-stable"
|
||||
? effectiveChannel
|
||||
: (configChannel ?? undefined);
|
||||
const command = formatManagedServiceUpdateCommand({
|
||||
timeoutMs,
|
||||
...(handoffChannel ? { channel: handoffChannel } : {}),
|
||||
@@ -468,7 +520,12 @@ export const updateHandlers: GatewayRequestHandlers = {
|
||||
timeoutMs,
|
||||
cwd: root,
|
||||
argv1: process.argv[1],
|
||||
channel: configChannel ?? undefined,
|
||||
channel:
|
||||
installSurface.kind === "git"
|
||||
? (configChannel ?? undefined)
|
||||
: effectiveChannel === "extended-stable"
|
||||
? effectiveChannel
|
||||
: (configChannel ?? undefined),
|
||||
...(adoptedPackageTargetVersion ? { tag: adoptedPackageTargetVersion } : {}),
|
||||
...(adoptedDevTargetRef ? { devTargetRef: adoptedDevTargetRef } : {}),
|
||||
allowGatewayServiceRepair: false,
|
||||
|
||||
@@ -102,6 +102,14 @@ describe("resolveEffectiveUpdateChannel", () => {
|
||||
},
|
||||
expected: { channel: "extended-stable", source: "config" },
|
||||
},
|
||||
{
|
||||
name: "uses installed extended-stable version without config",
|
||||
params: {
|
||||
currentVersion: "2026.6.33",
|
||||
installKind: "package" as const,
|
||||
},
|
||||
expected: { channel: "extended-stable", source: "installed-version" },
|
||||
},
|
||||
{
|
||||
name: "uses beta git tag",
|
||||
params: { installKind: "git" as const, git: { tag: "v2026.2.24-beta.1" } },
|
||||
@@ -242,6 +250,20 @@ describe("resolveUpdateChannelDisplay", () => {
|
||||
});
|
||||
|
||||
describe("resolveRegistryUpdateChannel", () => {
|
||||
it.each([
|
||||
{ currentVersion: "2026.6.32", expected: "stable" },
|
||||
{ currentVersion: "2026.6.33", expected: "stable" },
|
||||
{ currentVersion: "2026.6.34", expected: "stable" },
|
||||
{ currentVersion: "2026.6.33-1", expected: "stable" },
|
||||
{ currentVersion: "1.33.1", expected: "stable" },
|
||||
{ currentVersion: "1.6.33", expected: "stable" },
|
||||
] as const)(
|
||||
"does not infer a package-only channel for $currentVersion",
|
||||
({ currentVersion, expected }) => {
|
||||
expect(resolveRegistryUpdateChannel({ currentVersion })).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("queries beta when the installed version is beta even if config is stale stable", () => {
|
||||
expect(
|
||||
resolveRegistryUpdateChannel({
|
||||
|
||||
@@ -61,6 +61,21 @@ export function isBetaTag(tag: string): boolean {
|
||||
return /(?:^|[.-])beta(?:[.-]|$)/i.test(tag);
|
||||
}
|
||||
|
||||
/** Returns whether a final monthly release belongs to the extended-stable line. */
|
||||
function isExtendedStableReleaseVersion(version: string): boolean {
|
||||
const parsed = parseSemver(version.trim());
|
||||
return (
|
||||
parsed !== null &&
|
||||
parsed.build.length === 0 &&
|
||||
parsed.prerelease.length === 0 &&
|
||||
parsed.major >= 1000 &&
|
||||
parsed.major <= 9999 &&
|
||||
parsed.minor >= 1 &&
|
||||
parsed.minor <= 12 &&
|
||||
parsed.patch >= 33
|
||||
);
|
||||
}
|
||||
|
||||
/** Detects prerelease tags, including legacy dot-beta tags and named prerelease channels. */
|
||||
function isPrereleaseTag(tag: string): boolean {
|
||||
const parsed = parseSemver(normalizeLegacyDotBetaVersion(tag));
|
||||
@@ -115,6 +130,12 @@ export function resolveEffectiveUpdateChannel(params: {
|
||||
return { channel: params.configChannel, source: "config" };
|
||||
}
|
||||
|
||||
if (params.installKind === "package" && params.currentVersion) {
|
||||
if (isExtendedStableReleaseVersion(params.currentVersion)) {
|
||||
return { channel: "extended-stable", source: "installed-version" };
|
||||
}
|
||||
}
|
||||
|
||||
if (params.installKind === "git") {
|
||||
const tag = params.git?.tag;
|
||||
if (tag) {
|
||||
@@ -156,7 +177,7 @@ export function formatUpdateChannelLabel(params: {
|
||||
: `${params.channel} (branch)`;
|
||||
}
|
||||
if (params.source === "installed-version") {
|
||||
return "beta (installed version)";
|
||||
return `${params.channel} (installed version)`;
|
||||
}
|
||||
return `${params.channel} (default)`;
|
||||
}
|
||||
|
||||
@@ -816,6 +816,31 @@ describe("checkUpdateStatus", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a status registry channel after detecting the install kind", async () => {
|
||||
await withTempDir({ prefix: "openclaw-update-check-registry-channel-" }, async (root) => {
|
||||
await fs.writeFile(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ packageManager: "npm@10.0.0" }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(root, "package-lock.json"), "lock", "utf8");
|
||||
await fs.mkdir(path.join(root, "node_modules"), { recursive: true });
|
||||
const resolveRegistryChannel = vi.fn(() => "extended-stable" as const);
|
||||
|
||||
await checkUpdateStatus({
|
||||
root,
|
||||
includeRegistry: false,
|
||||
fetchGit: false,
|
||||
resolveRegistryChannel,
|
||||
});
|
||||
|
||||
expect(resolveRegistryChannel).toHaveBeenCalledWith({
|
||||
installKind: "package",
|
||||
git: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "text lockfile",
|
||||
|
||||
+21
-15
@@ -592,22 +592,28 @@ export async function checkUpdateStatus(params: {
|
||||
fetchGit?: boolean;
|
||||
includeRegistry?: boolean;
|
||||
registryChannel?: UpdateChannel;
|
||||
resolveRegistryChannel?: (
|
||||
status: Pick<UpdateCheckResult, "installKind" | "git">,
|
||||
) => UpdateChannel;
|
||||
}): Promise<UpdateCheckResult> {
|
||||
const timeoutMs = params.timeoutMs ?? 6000;
|
||||
const fetchRegistry = () =>
|
||||
params.registryChannel
|
||||
const resolveRegistryChannel = (status: Pick<UpdateCheckResult, "installKind" | "git">) =>
|
||||
params.registryChannel ?? params.resolveRegistryChannel?.(status);
|
||||
const fetchRegistry = (registryChannel: UpdateChannel | undefined) =>
|
||||
registryChannel
|
||||
? fetchNpmRegistryVersionForChannel({
|
||||
channel: params.registryChannel,
|
||||
channel: registryChannel,
|
||||
timeoutMs,
|
||||
})
|
||||
: fetchNpmLatestVersion({ timeoutMs });
|
||||
const root = params.root ? path.resolve(params.root) : null;
|
||||
if (!root) {
|
||||
const registryChannel = resolveRegistryChannel({ installKind: "unknown" });
|
||||
return {
|
||||
root: null,
|
||||
installKind: "unknown",
|
||||
packageManager: "unknown",
|
||||
registry: params.includeRegistry ? await fetchRegistry() : undefined,
|
||||
registry: params.includeRegistry ? await fetchRegistry(registryChannel) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -626,17 +632,6 @@ export async function checkUpdateStatus(params: {
|
||||
? "npm"
|
||||
: detectedPackageManager;
|
||||
|
||||
const registry = params.includeRegistry
|
||||
? params.registryChannel === "extended-stable" && isGit
|
||||
? {
|
||||
latestVersion: null,
|
||||
tag: "extended-stable",
|
||||
error: "unsupported_git_channel",
|
||||
reason: "unsupported_git_channel" as const,
|
||||
}
|
||||
: await fetchRegistry()
|
||||
: undefined;
|
||||
|
||||
const installKind: UpdateCheckResult["installKind"] = isGit ? "git" : "package";
|
||||
const [git, deps] = await Promise.all([
|
||||
isGit
|
||||
@@ -648,6 +643,17 @@ export async function checkUpdateStatus(params: {
|
||||
: Promise.resolve(undefined),
|
||||
checkDepsStatus({ root, manager: packageManager }),
|
||||
]);
|
||||
const registryChannel = resolveRegistryChannel({ installKind, git });
|
||||
const registry = params.includeRegistry
|
||||
? registryChannel === "extended-stable" && isGit
|
||||
? {
|
||||
latestVersion: null,
|
||||
tag: "extended-stable",
|
||||
error: "unsupported_git_channel",
|
||||
reason: "unsupported_git_channel" as const,
|
||||
}
|
||||
: await fetchRegistry(registryChannel)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
root,
|
||||
|
||||
@@ -28,6 +28,7 @@ const {
|
||||
refreshRemoteModelCatalogMock,
|
||||
scheduleGatewaySigusr1RestartMock,
|
||||
startManagedServiceUpdateHandoffMock,
|
||||
versionMock,
|
||||
} = vi.hoisted(() => ({
|
||||
detectRespawnSupervisorMock: vi.fn(),
|
||||
getRuntimeConfigMock: vi.fn(() => ({})),
|
||||
@@ -48,6 +49,7 @@ const {
|
||||
command: "openclaw update --yes --channel beta --timeout 2700",
|
||||
logPath: "/tmp/openclaw-handoff.log",
|
||||
})),
|
||||
versionMock: { value: "1.0.0" },
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
@@ -111,7 +113,9 @@ vi.mock("./update-check.js", async () => {
|
||||
});
|
||||
|
||||
vi.mock("../version.js", () => ({
|
||||
VERSION: "1.0.0",
|
||||
get VERSION() {
|
||||
return versionMock.value;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
@@ -229,6 +233,7 @@ describe("update-startup", () => {
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
versionMock.value = "1.0.0";
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-17T10:00:00Z"));
|
||||
testState = await createOpenClawTestState({
|
||||
@@ -779,6 +784,49 @@ describe("update-startup", () => {
|
||||
await expectPathMissing(path.join(tempDir, "update-check.json"));
|
||||
});
|
||||
|
||||
it("uses the extended-stable selector for an installed final extended-stable package", async () => {
|
||||
versionMock.value = "2026.6.33";
|
||||
mockPackageUpdateStatus("extended-stable", "2026.7.33");
|
||||
const onUpdateAvailableChange = vi.fn();
|
||||
|
||||
await runGatewayUpdateCheck({
|
||||
cfg: {},
|
||||
log: { info: vi.fn() },
|
||||
isNixMode: false,
|
||||
allowInTests: true,
|
||||
onUpdateAvailableChange,
|
||||
});
|
||||
|
||||
expect(resolveNpmChannelTag).toHaveBeenCalledWith({
|
||||
channel: "extended-stable",
|
||||
timeoutMs: 2500,
|
||||
});
|
||||
expect(onUpdateAvailableChange).toHaveBeenCalledWith({
|
||||
currentVersion: "2026.6.33",
|
||||
latestVersion: "2026.7.33",
|
||||
channel: "extended-stable",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not query extended-stable when configless startup hints are disabled", async () => {
|
||||
versionMock.value = "2026.6.33";
|
||||
mockPackageInstallStatus();
|
||||
const runAutoUpdate = createAutoUpdateSuccessMock();
|
||||
|
||||
await runGatewayUpdateCheck({
|
||||
cfg: { update: { checkOnStart: false, auto: { enabled: true } } },
|
||||
log: { info: vi.fn() },
|
||||
isNixMode: false,
|
||||
allowInTests: true,
|
||||
runAutoUpdate,
|
||||
});
|
||||
|
||||
expect(checkUpdateStatus).toHaveBeenCalledOnce();
|
||||
expect(resolveNpmChannelTag).not.toHaveBeenCalled();
|
||||
expect(runAutoUpdate).not.toHaveBeenCalled();
|
||||
expect(readPersistedUpdateCheckState()).toBeNull();
|
||||
});
|
||||
|
||||
it("discovers and deduplicates an exact extended-stable update without auto-applying", async () => {
|
||||
const onUpdateAvailableChange = vi.fn();
|
||||
const runAutoUpdate = createAutoUpdateSuccessMock();
|
||||
@@ -972,6 +1020,24 @@ describe("update-startup", () => {
|
||||
expectStableAutoRolloutStatePreserved();
|
||||
});
|
||||
|
||||
it("uses the verified package install kind for a configless extended-stable release", async () => {
|
||||
versionMock.value = "2026.6.33";
|
||||
mockPackageInstallStatus();
|
||||
mockNpmChannelTag("extended-stable", "2026.6.34");
|
||||
|
||||
await runGatewayUpdateCheck({
|
||||
cfg: {},
|
||||
log: { info: vi.fn() },
|
||||
isNixMode: false,
|
||||
allowInTests: true,
|
||||
});
|
||||
|
||||
expect(resolveNpmChannelTag).toHaveBeenCalledWith({
|
||||
channel: "extended-stable",
|
||||
timeoutMs: 2500,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips all extended-stable work in Nix mode", async () => {
|
||||
const runAutoUpdate = createAutoUpdateSuccessMock();
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import { gatewayUpdateCampaign, type UpdateCampaignController } from "./update-c
|
||||
import {
|
||||
channelToNpmTag,
|
||||
normalizeUpdateChannel,
|
||||
resolveEffectiveUpdateChannel,
|
||||
DEFAULT_PACKAGE_CHANNEL,
|
||||
type UpdateChannel,
|
||||
} from "./update-channels.js";
|
||||
@@ -838,12 +839,55 @@ export async function runGatewayUpdateCheck(params: {
|
||||
if (params.isNixMode) {
|
||||
return;
|
||||
}
|
||||
const configuredChannel =
|
||||
normalizeUpdateChannel(params.cfg.update?.channel) ?? DEFAULT_PACKAGE_CHANNEL;
|
||||
const configChannel = normalizeUpdateChannel(params.cfg.update?.channel);
|
||||
const updateCampaign = params.updateCampaign ?? gatewayUpdateCampaign;
|
||||
const auto = resolveAutoUpdatePolicy(params.cfg);
|
||||
const autoDisabledByEnv = isTruthyEnvValue(process.env.OPENCLAW_NO_AUTO_UPDATE);
|
||||
const autoDisabledByExternalSupervisor = isGatewayExternallySupervised();
|
||||
const shouldRunUpdateHints = params.cfg.update?.checkOnStart !== false;
|
||||
const potentialChannel = resolveEffectiveUpdateChannel({
|
||||
configChannel,
|
||||
currentVersion: VERSION,
|
||||
installKind: "package",
|
||||
}).channel;
|
||||
const potentialAutoDesired =
|
||||
(potentialChannel === "stable" || potentialChannel === "beta" || potentialChannel === "dev") &&
|
||||
auto.enabled &&
|
||||
!autoDisabledByEnv &&
|
||||
!autoDisabledByExternalSupervisor;
|
||||
if (!shouldRunUpdateHints && !potentialAutoDesired && configChannel === "extended-stable") {
|
||||
updateCampaign.clear();
|
||||
setUpdateAvailableCache({
|
||||
next: null,
|
||||
onUpdateAvailableChange: params.onUpdateAvailableChange,
|
||||
});
|
||||
const priorSchedule =
|
||||
updateScheduleCache?.channel === potentialChannel ? updateScheduleCache : null;
|
||||
const initialSchedule: UpdateScheduleState = priorSchedule
|
||||
? { ...priorSchedule, autoEnabled: auto.enabled }
|
||||
: { channel: potentialChannel, autoEnabled: auto.enabled };
|
||||
setUpdateScheduleCache({
|
||||
next: withoutTarget(initialSchedule),
|
||||
onUpdateScheduleChange: params.onUpdateScheduleChange,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const mightUseInstalledExtendedStableChannel =
|
||||
configChannel === null && potentialChannel === "extended-stable";
|
||||
let installStatus: Awaited<ReturnType<typeof resolveStartupInstallStatus>> | undefined;
|
||||
if (
|
||||
configChannel === "extended-stable" ||
|
||||
configChannel === "dev" ||
|
||||
mightUseInstalledExtendedStableChannel
|
||||
) {
|
||||
installStatus = await resolveStartupInstallStatus(configChannel === "dev");
|
||||
}
|
||||
const configuredChannel = resolveEffectiveUpdateChannel({
|
||||
configChannel,
|
||||
currentVersion: VERSION,
|
||||
installKind: installStatus?.status.installKind ?? "unknown",
|
||||
git: installStatus?.status.git,
|
||||
}).channel;
|
||||
const autoDesired =
|
||||
(configuredChannel === "stable" ||
|
||||
configuredChannel === "beta" ||
|
||||
@@ -851,7 +895,6 @@ export async function runGatewayUpdateCheck(params: {
|
||||
auto.enabled &&
|
||||
!autoDisabledByEnv &&
|
||||
!autoDisabledByExternalSupervisor;
|
||||
const shouldRunUpdateHints = params.cfg.update?.checkOnStart !== false;
|
||||
|
||||
if (updateScheduleCache?.channel !== configuredChannel) {
|
||||
updateCampaign.clear();
|
||||
@@ -918,9 +961,10 @@ export async function runGatewayUpdateCheck(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
let installStatus: Awaited<ReturnType<typeof resolveStartupInstallStatus>> | undefined;
|
||||
if (configuredChannel === "extended-stable" || configuredChannel === "dev") {
|
||||
if ((configuredChannel === "extended-stable" || configuredChannel === "dev") && !installStatus) {
|
||||
installStatus = await resolveStartupInstallStatus(configuredChannel === "dev");
|
||||
}
|
||||
if (installStatus && (configuredChannel === "extended-stable" || configuredChannel === "dev")) {
|
||||
setUpdateScheduleCache({
|
||||
next: withInstallStatus(
|
||||
updateScheduleCache ?? initialSchedule,
|
||||
|
||||
@@ -22,11 +22,13 @@ describe("make_appcast cleanup", () => {
|
||||
expect(setupBlock).toContain('rm -f "$NOTES_HTML"');
|
||||
});
|
||||
|
||||
it("adds the beta channel and refuses alpha releases", () => {
|
||||
it("adds release-train channels and refuses alpha releases", () => {
|
||||
const script = readFileSync(scriptPath, "utf8");
|
||||
|
||||
expect(script).toContain('if [[ "$VERSION" == *-beta.* || "$VERSION" == *.beta.* ]]; then');
|
||||
expect(script).toContain("CHANNEL_ARGS=(--channel beta)");
|
||||
expect(script).toContain("CHANNEL_ARGS=(--channel extended-stable)");
|
||||
expect(script).toContain('"${BASH_REMATCH[1]}" -ge 33');
|
||||
expect(script).toContain('if [[ "$VERSION" == *-alpha.* || "$VERSION" == *.alpha.* ]]; then');
|
||||
expect(script).toContain('"${CHANNEL_ARGS[@]}"');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user