fix(ui): identify source builds in settings footer (#116134)

* fix(ui): identify source builds in settings footer

* fix(release): classify all official Control UI builds
This commit is contained in:
Vincent Koc
2026-07-30 08:58:34 +08:00
committed by GitHub
parent 545350488a
commit 1065c1d697
25 changed files with 205 additions and 33 deletions
+2
View File
@@ -59,6 +59,8 @@ jobs:
run: pnpm build
- name: Build Control UI
env:
OPENCLAW_CONTROL_UI_RELEASE_BUILD: "1"
run: pnpm ui:build
- name: Validate release tag and package metadata
@@ -199,6 +199,8 @@ jobs:
- name: Build
if: steps.dist_build_cache.outputs.cache-hit != 'true'
env:
OPENCLAW_CONTROL_UI_RELEASE_BUILD: "1"
run: pnpm build
- name: Pack publishable core packages
@@ -244,6 +246,8 @@ jobs:
- name: Build Control UI
if: steps.dist_build_cache.outputs.cache-hit != 'true'
env:
OPENCLAW_CONTROL_UI_RELEASE_BUILD: "1"
run: pnpm ui:build
- name: Save preflight build outputs
+1
View File
@@ -2354,6 +2354,7 @@ const server = await createServer({
commit: "0123456789abcdef0123456789abcdef01234567",
commitAt: "2026-07-10T11:22:33.000Z",
builtAt: "2026-07-10T12:34:56.000Z",
release: false,
buildId: "mock",
}),
},
+1
View File
@@ -20,6 +20,7 @@ APP_VERSION_INPUT="${APP_VERSION:-}"
# Default to universal binary for distribution builds (supports both Apple Silicon and Intel Macs)
export BUILD_ARCHS="${BUILD_ARCHS:-all}"
export BUILD_CONFIG
export OPENCLAW_CONTROL_UI_RELEASE_BUILD=1
DSYM_ARCHS_VALUE="$BUILD_ARCHS"
if [[ "$DSYM_ARCHS_VALUE" == "all" ]]; then
DSYM_ARCHS_VALUE="arm64 x86_64"
+1 -1
View File
@@ -14,5 +14,5 @@ pnpm release:plugins:npm:check -- --selection-mode all-publishable
pnpm release:plugins:clawhub:check -- --selection-mode all-publishable
node --import tsx scripts/plugin-release-pretag-pack-check.ts
pnpm build
pnpm ui:build
OPENCLAW_CONTROL_UI_RELEASE_BUILD=1 pnpm ui:build
pnpm release:openclaw:npm:check
@@ -165,10 +165,12 @@ describe("minimal npm extended-stable workflow", () => {
// Only the build producers skip on a cache hit; every validation step
// still runs against the restored artifacts.
expect(step(preflight, "Build").if).toBe("steps.dist_build_cache.outputs.cache-hit != 'true'");
expect(step(preflight, "Build Control UI").if).toBe(
"steps.dist_build_cache.outputs.cache-hit != 'true'",
);
const build = step(preflight, "Build");
const buildControlUi = step(preflight, "Build Control UI");
expect(build.if).toBe("steps.dist_build_cache.outputs.cache-hit != 'true'");
expect(build.env?.OPENCLAW_CONTROL_UI_RELEASE_BUILD).toBe("1");
expect(buildControlUi.if).toBe("steps.dist_build_cache.outputs.cache-hit != 'true'");
expect(buildControlUi.env?.OPENCLAW_CONTROL_UI_RELEASE_BUILD).toBe("1");
expect(step(preflight, "Check").if).toBeUndefined();
expect(step(preflight, "Verify release contents").if).toBeUndefined();
expect(step(preflight, "Verify prepared npm tarball install").if).toBeUndefined();
@@ -4431,9 +4431,17 @@ wait_for_run plugin-clawhub-new.yml 123 "${expectedSha}" || status=$?
"validate_macos_release_request",
);
const stepNames = validateJob.steps?.map((step) => step.name) ?? [];
const buildControlUi = validateJob.steps?.find((step) => step.name === "Build Control UI");
expect(stepNames).not.toContain("Ensure matching GitHub release exists");
expect(macosRelease.jobs?.validate_macos_release_request).toBeDefined();
expect(buildControlUi?.env?.OPENCLAW_CONTROL_UI_RELEASE_BUILD).toBe("1");
});
it("classifies fast pretag Control UI output as a release artifact", () => {
const script = readFileSync("scripts/release-fast-pretag-check.sh", "utf8");
expect(script).toContain("OPENCLAW_CONTROL_UI_RELEASE_BUILD=1 pnpm ui:build");
});
it("keeps every tracked repository skill visible to Git-aware syncs", () => {
+9
View File
@@ -91,6 +91,15 @@ describe("package-mac-dist plist validation", () => {
expect(releaseBlock).not.toContain("*.debug");
});
it("marks the distributed Control UI as an official release artifact", () => {
const script = readFileSync(scriptPath, "utf8");
const releaseMarkerIndex = script.indexOf("export OPENCLAW_CONTROL_UI_RELEASE_BUILD=1");
const packageAppIndex = script.indexOf('"$ROOT_DIR/scripts/package-mac-app.sh"');
expect(releaseMarkerIndex).toBeGreaterThanOrEqual(0);
expect(packageAppIndex).toBeGreaterThan(releaseMarkerIndex);
});
it("does not mask canonical Sparkle build failures for release packaging", () => {
const script = readFileSync(scriptPath, "utf8");
+1 -1
View File
@@ -2033,7 +2033,7 @@ class OpenClawShell extends OpenClawLightDomElement {
offline: gatewaySnapshot.offlineStable,
queuedOutboxCount: storedOutboxes?.total ?? 0,
lastError: gatewaySnapshot.lastError,
version:
gatewayVersion:
context.config.current.serverVersion ?? gatewaySnapshot.hello?.server?.version ?? "",
updateAvailable: navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable,
updateRunning: overlaySnapshot.updateRunning,
+1
View File
@@ -18,6 +18,7 @@ vi.mock("../build-info.ts", () => ({
builtAt: null,
branch: null,
dirty: null,
release: false,
buildId: "test",
},
}));
+35
View File
@@ -62,6 +62,7 @@ describe("Control UI Vite config", () => {
builtAt: "2026-07-10T12:34:56.000Z",
branch: null,
dirty: null,
release: false,
buildId: "2026.7.10-0123456789ab-2026-07-10T12-34-56.000Z",
});
expect(readGitCommit).not.toHaveBeenCalled();
@@ -86,10 +87,44 @@ describe("Control UI Vite config", () => {
builtAt: "2026-07-10T13:14:15.000Z",
branch: null,
dirty: null,
release: false,
buildId: "aaaaaaaaaaaa-2026-07-10T13-14-15.000Z",
});
});
it("records release packaging as an explicit artifact fact", () => {
expect(
resolveControlUiBuildInfo({
env: {
OPENCLAW_CONTROL_UI_RELEASE_BUILD: "1",
OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T13:14:15.000Z",
},
readGitCommit: () => "a".repeat(40),
readGitCommitTimestamp: () => null,
readGitBranch: () => "release/2026.7.10",
readGitDirty: () => false,
readPackageVersion: () => "2026.7.10",
}),
).toMatchObject({
version: "2026.7.10",
commit: "a".repeat(40),
branch: "release/2026.7.10",
dirty: false,
release: true,
buildId: "2026.7.10-release-aaaaaaaaaaaa-2026-07-10T13-14-15.000Z",
});
});
it("rejects malformed release-build identity", () => {
expect(() =>
resolveControlUiBuildInfo({
env: { OPENCLAW_CONTROL_UI_RELEASE_BUILD: "true" },
readGitCommit: () => null,
readPackageVersion: () => "2026.7.10",
}),
).toThrow("OPENCLAW_CONTROL_UI_RELEASE_BUILD must be 1 when set");
});
it("uses checked-out Git instead of unverified GitHub workflow context", () => {
const readGitCommit = vi.fn(() => "c".repeat(40));
expect(
+12 -3
View File
@@ -3,7 +3,10 @@
import { truncateUtf16Safe } from "../../packages/normalization-core/src/utf16-slice.js";
import type { ControlUiBuildInfo } from "./build-info-types.ts";
type ControlUiBuildMetadata = Pick<ControlUiBuildInfo, "version" | "commit" | "builtAt">;
type ControlUiBuildMetadata = Pick<
ControlUiBuildInfo,
"version" | "commit" | "builtAt" | "release"
>;
const FULL_GIT_SHA = /^[0-9a-f]{40}$/u;
const UTC_BUILD_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u;
@@ -44,7 +47,12 @@ function normalizeControlUiBuildId(value: unknown): string {
}
function deriveControlUiBuildId(info: ControlUiBuildMetadata): string {
const identity = [info.version, info.commit?.slice(0, 12), info.builtAt]
const identity = [
info.version,
info.release ? "release" : null,
info.commit?.slice(0, 12),
info.builtAt,
]
.filter((value): value is string => Boolean(value))
.join("-");
return normalizeControlUiBuildId(identity);
@@ -57,7 +65,8 @@ export function normalizeControlUiBuildInfo(value: unknown): ControlUiBuildInfo
const version = optionalString(record.version);
const commit = normalizeControlUiCommit(record.commit);
const builtAt = normalizeControlUiBuildTimestamp(record.builtAt);
const metadata = { version, commit, builtAt };
const release = record.release === true;
const metadata = { version, commit, builtAt, release };
return {
...metadata,
commitAt: normalizeControlUiBuildTimestamp(record.commitAt),
+1
View File
@@ -5,5 +5,6 @@ export type ControlUiBuildInfo = Readonly<{
builtAt: string | null;
branch: string | null;
dirty: boolean | null;
release: boolean;
buildId: string;
}>;
+9 -3
View File
@@ -77,6 +77,7 @@ describe("Control UI build info", () => {
builtAt: "later",
branch: "HEAD",
dirty: "yes",
release: "yes",
buildId: "",
}),
).toEqual({
@@ -86,14 +87,18 @@ describe("Control UI build info", () => {
builtAt: null,
branch: null,
dirty: null,
release: false,
buildId: "dev",
});
});
it("passes through normalized branch and boolean dirty state", () => {
expect(normalizeControlUiBuildInfo({ branch: " feature/x ", dirty: false })).toMatchObject({
it("passes through normalized branch, dirty state, and release identity", () => {
expect(
normalizeControlUiBuildInfo({ branch: " feature/x ", dirty: false, release: true }),
).toMatchObject({
branch: "feature/x",
dirty: false,
release: true,
});
});
@@ -103,7 +108,8 @@ describe("Control UI build info", () => {
version: "2026.7.10",
commit: COMMIT,
builtAt: "2026-07-10T12:34:56.000Z",
release: true,
}).buildId,
).toBe("2026.7.10-0123456789ab-2026-07-10T12-34-56.000Z");
).toBe("2026.7.10-release-0123456789ab-2026-07-10T12-34-56.000Z");
});
});
+26 -12
View File
@@ -40,7 +40,7 @@ describe("settings sidebar search", () => {
activeRouteId: "model-setup",
offline: false,
lastError: null,
version: "",
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
onUpdate: vi.fn(),
@@ -71,7 +71,7 @@ describe("settings sidebar search", () => {
activeRouteId: "appearance",
offline: false,
lastError: null,
version: "",
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
onUpdate: vi.fn(),
@@ -101,7 +101,7 @@ describe("settings sidebar search", () => {
activeRouteId: "appearance",
offline: false,
lastError: null,
version: "",
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
onUpdate: vi.fn(),
@@ -139,7 +139,7 @@ describe("settings sidebar search", () => {
activeRouteId: "appearance",
offline: false,
lastError: null,
version: "",
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
onUpdate: vi.fn(),
@@ -194,7 +194,7 @@ describe("settings sidebar search", () => {
activeRouteId: "appearance",
offline: false,
lastError: null,
version: "",
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
onUpdate: vi.fn(),
@@ -242,7 +242,7 @@ describe("settings sidebar search", () => {
activeRouteId: "agents",
offline: false,
lastError: null,
version: "",
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
onUpdate: vi.fn(),
@@ -273,7 +273,7 @@ describe("settings sidebar search", () => {
activeHash: "#memory-backend",
offline: false,
lastError: null,
version: "",
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
onUpdate: vi.fn(),
@@ -318,7 +318,7 @@ describe("settings sidebar search", () => {
activeRouteId: "appearance",
offline: false,
lastError: null,
version: "",
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
onUpdate: vi.fn(),
@@ -399,7 +399,7 @@ describe("settings sidebar search", () => {
activeRouteId: "appearance",
offline: false,
lastError: null,
version: "",
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
onUpdate: vi.fn(),
@@ -424,13 +424,14 @@ describe("settings sidebar search", () => {
it("keeps the update card above the settings footer", async () => {
const onUpdate = vi.fn();
const onNavigate = vi.fn();
render(
renderSettingsSidebar({
basePath: "",
activeRouteId: "appearance",
offline: false,
lastError: null,
version: "1.0.0",
gatewayVersion: "1.0.0",
updateAvailable: {
currentVersion: "1.0.0",
latestVersion: "2.0.0",
@@ -441,7 +442,7 @@ describe("settings sidebar search", () => {
searchQuery: "",
onExit: vi.fn(),
onRetryConnect: vi.fn(),
onNavigate: vi.fn(),
onNavigate,
onSearchQueryChange: vi.fn(),
preloadTimers: new Map(),
saveIndicator: saveIndicator(),
@@ -456,6 +457,19 @@ describe("settings sidebar search", () => {
expect(card?.nextElementSibling?.classList.contains("settings-sidebar__footer")).toBe(true);
card?.querySelector<HTMLButtonElement>(".sidebar-update-card__action")?.click();
expect(onUpdate).toHaveBeenCalledOnce();
const buildChip = container.querySelector<
HTMLElement & {
gatewayVersion: string | null;
variant: string;
updateComplete: Promise<boolean>;
}
>("openclaw-sidebar-build-chip");
await buildChip?.updateComplete;
expect(buildChip?.gatewayVersion).toBe("1.0.0");
expect(buildChip?.variant).toBe("settings");
buildChip?.querySelector<HTMLAnchorElement>(".sidebar-footer-build")?.click();
expect(onNavigate).toHaveBeenCalledWith("about");
});
it("shows the offline retry action without an online status", () => {
@@ -468,7 +482,7 @@ describe("settings sidebar search", () => {
offline,
queuedOutboxCount,
lastError,
version: "1.0.0",
gatewayVersion: "1.0.0",
updateAvailable: null,
updateRunning: false,
onUpdate: vi.fn(),
+8 -4
View File
@@ -23,6 +23,7 @@ import { redactLoginFailureError } from "./login-gate.ts";
import { renderOfflineSidebarStatus } from "./session-row-badges.ts";
import type { SettingsSaveIndicatorProps } from "./settings-save-indicator.ts";
import "./settings-save-indicator.ts";
import "./sidebar-build-chip.ts";
import "./sidebar-update-card.ts";
type SettingsSidebarProps = {
@@ -34,7 +35,7 @@ type SettingsSidebarProps = {
offline: boolean;
queuedOutboxCount?: number;
lastError: string | null;
version: string;
gatewayVersion: string;
updateAvailable: UpdateAvailable | null;
updateRunning: boolean;
onUpdate: () => void;
@@ -329,9 +330,12 @@ export function renderSettingsSidebar(props: SettingsSidebarProps) {
: html`<openclaw-settings-save-indicator
.props=${props.saveIndicator}
></openclaw-settings-save-indicator>`}
${props.version
? html`<span class="settings-sidebar__footer-version">${props.version}</span>`
: nothing}
<openclaw-sidebar-build-chip
.basePath=${props.basePath}
.gatewayVersion=${props.gatewayVersion || null}
.variant=${"settings"}
.onNavigate=${() => props.onNavigate("about")}
></openclaw-sidebar-build-chip>
</footer>
</aside>
`;
@@ -25,6 +25,31 @@ export function formatBuildChipText(info: ControlUiBuildInfo): string | null {
return `${branch}${commit}`;
}
export function formatSettingsBuildLabel(
info: ControlUiBuildInfo,
gatewayVersion: string | null,
): string | null {
const version = info.version ?? gatewayVersion;
const commit = info.commit;
if (!commit) {
return version;
}
const compactBuild = formatBuildChipText(info);
if (!compactBuild) {
return version;
}
if (info.release) {
return version;
}
const gitIdentity =
info.branch && info.branch !== "main"
? compactBuild
: `git@${commit.slice(0, 7)}${info.dirty === true ? "*" : ""}`;
return [version, gitIdentity].filter((value): value is string => Boolean(value)).join(" · ");
}
function formatBuildCardDetails(info: ControlUiBuildInfo, gatewayVersion: string | null) {
return {
summary: [
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ControlUiBuildInfo } from "../build-info.ts";
import { formatBuildChipText } from "./sidebar-build-chip-format.ts";
import { formatBuildChipText, formatSettingsBuildLabel } from "./sidebar-build-chip-format.ts";
const COMMIT = "e8cbc62f0123456789abcdef0123456789abcdef";
const BUILT_AT = "2026-07-10T12:00:00.000Z";
@@ -13,6 +13,7 @@ function buildInfo(overrides: Partial<ControlUiBuildInfo> = {}): ControlUiBuildI
builtAt: BUILT_AT,
branch: "main",
dirty: false,
release: false,
buildId: "test",
...overrides,
};
@@ -67,3 +68,34 @@ describe("formatBuildChipText", () => {
});
}
});
describe("formatSettingsBuildLabel", () => {
it("keeps official release artifacts version-only", () => {
expect(formatSettingsBuildLabel(buildInfo({ release: true }), "2026.7.9")).toBe("2026.7.10");
});
it("adds a Git identity for clean main builds", () => {
expect(formatSettingsBuildLabel(buildInfo(), "2026.7.9")).toBe("2026.7.10 · git@e8cbc62");
});
it("adds branch and dirty provenance for development builds", () => {
expect(formatSettingsBuildLabel(buildInfo({ branch: "feat/x", dirty: true }), "2026.7.9")).toBe(
"2026.7.10 · feat/x@e8cbc62*",
);
});
it("falls back to the Gateway version when artifact metadata is unavailable", () => {
expect(
formatSettingsBuildLabel(
buildInfo({ version: null, commit: null, branch: null, dirty: null }),
"2026.7.9",
),
).toBe("2026.7.9");
});
it("keeps detached clean source builds distinguishable from releases", () => {
expect(formatSettingsBuildLabel(buildInfo({ branch: null, dirty: false }), "2026.7.9")).toBe(
"2026.7.10 · git@e8cbc62",
);
});
});
+10 -2
View File
@@ -4,7 +4,11 @@ import { pathForRoute } from "../app-route-paths.ts";
import { CONTROL_UI_BUILD_INFO } from "../build-info.ts";
import { t } from "../i18n/index.ts";
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
import { formatBuildChipText, renderSidebarServerDetails } from "./sidebar-build-chip-format.ts";
import {
formatBuildChipText,
formatSettingsBuildLabel,
renderSidebarServerDetails,
} from "./sidebar-build-chip-format.ts";
import "./tooltip.ts";
function shouldHandleNavigationClick(event: MouseEvent): boolean {
@@ -23,9 +27,13 @@ class SidebarBuildChip extends OpenClawLightDomContentsElement {
@property({ attribute: false }) basePath = "";
@property({ attribute: false }) gatewayVersion: string | null = null;
@property({ attribute: false }) onNavigate?: (routeId: "about") => void;
@property({ attribute: false }) variant: "compact" | "settings" = "compact";
override render() {
const text = formatBuildChipText(CONTROL_UI_BUILD_INFO);
const text =
this.variant === "settings"
? formatSettingsBuildLabel(CONTROL_UI_BUILD_INFO, this.gatewayVersion)
: formatBuildChipText(CONTROL_UI_BUILD_INFO);
if (!text) {
return nothing;
}
@@ -166,6 +166,7 @@ const BUILD_INFO: ControlUiBuildInfo = {
builtAt: "2026-07-20T10:30:00.000Z",
branch: "main",
dirty: true,
release: false,
buildId: "test",
};
@@ -85,6 +85,7 @@ describeControlUiE2e("Control UI Unicode build identity mocked Gateway E2E", ()
builtAt: "2026-07-10T12:34:56.000Z",
branch: RAW_BRANCH,
dirty: true,
release: false,
buildId: "build-info-unicode-e2e",
});
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
+2
View File
@@ -20,6 +20,7 @@ function createProps(overrides: Partial<AboutProps> = {}): AboutProps {
builtAt: BUILT_AT,
branch: "feature/build-chip",
dirty: true,
release: false,
buildId: "test",
},
gatewayVersion: "2026.7.9",
@@ -162,6 +163,7 @@ describe("renderAbout", () => {
builtAt: null,
branch: null,
dirty: null,
release: false,
buildId: "dev",
},
gatewayVersion: null,
+1 -1
View File
@@ -681,7 +681,7 @@ openclaw-settings-save-indicator {
}
}
.settings-sidebar__footer-version {
.settings-sidebar__footer .sidebar-footer-build {
margin-left: auto;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
+1
View File
@@ -306,6 +306,7 @@ export async function startControlUiE2eServer(
builtAt: "2026-07-10T12:34:56.000Z",
branch: null,
dirty: false,
release: false,
buildId: "e2e",
},
): Promise<ControlUiE2eServer> {
+6 -1
View File
@@ -223,7 +223,12 @@ export function resolveControlUiBuildInfo(
normalizeControlUiBuildInfo({ branch: githubBranch }).branch ??
normalizeControlUiBuildInfo({ branch: (sources.readGitBranch ?? readGitBranch)() }).branch;
const dirty = (sources.readGitDirty ?? readGitDirty)();
const metadata = { version, commit, builtAt };
const releaseFlag = env.OPENCLAW_CONTROL_UI_RELEASE_BUILD?.trim();
if (releaseFlag && releaseFlag !== "1") {
throw new Error("OPENCLAW_CONTROL_UI_RELEASE_BUILD must be 1 when set");
}
const release = releaseFlag === "1";
const metadata = { version, commit, builtAt, release };
const explicitBuildId = env.OPENCLAW_CONTROL_UI_BUILD_ID?.trim();
return {
...metadata,