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:
Dallin Romney
2026-08-09 16:01:14 +08:00
committed by GitHub
parent da463bfef4
commit dcdbd7aab6
25 changed files with 715 additions and 148 deletions
+80 -4
View File
@@ -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
}
}
+3 -2
View File
@@ -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`
+2 -1
View File
@@ -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.
+3 -2
View File
@@ -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),
});
+2
View File
@@ -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)"
+7 -5
View File
@@ -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({
+17 -5
View File
@@ -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;
+2
View File
@@ -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");
});
});
+25 -5
View File
@@ -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);
+19 -94
View File
@@ -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");
});
});
+60 -3
View File
@@ -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,
+22
View File
@@ -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({
+22 -1
View File
@@ -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)`;
}
+25
View File
@@ -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
View File
@@ -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,
+67 -1
View File
@@ -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();
+49 -5
View File
@@ -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,
+3 -1
View File
@@ -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[@]}"');
});