diff --git a/.changeset/12970-about-build.md b/.changeset/12970-about-build.md
new file mode 100644
index 0000000000..4b83d2350c
--- /dev/null
+++ b/.changeset/12970-about-build.md
@@ -0,0 +1,9 @@
+---
+'@object-ui/app-shell': patch
+'@object-ui/i18n': patch
+'@object-ui/console': patch
+---
+
+The help menu gains an About entry on every console bar (the tenant console and the control plane draw the same header). It opens a small dialog naming the build the console was compiled from: `VITE_APP_VERSION` from the build's environment, or, when that is unset, the short sha of `HEAD` that the console's Vite config reads from git at build time. A console built with neither says "Unknown build" in words instead of showing an empty field.
+
+The dialog and Sentry's release fallback read the same value through one reader, so the two can no longer name different builds. The text comes from new `help.about*` keys in all ten language packs.
diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts
index 885f088070..da8b4de797 100644
--- a/apps/console/vite.config.ts
+++ b/apps/console/vite.config.ts
@@ -8,6 +8,7 @@ import react from '@vitejs/plugin-react';
import path from 'path';
import fs from 'fs';
import zlib from 'node:zlib';
+import { execFileSync } from 'node:child_process';
// Relative specifiers carry their real file extension, and `__dirname` is
// spelled `import.meta.dirname` throughout this file, so the config stays
// loadable by Vite's `configLoader: 'native'` — which imports this file with
@@ -587,6 +588,27 @@ function serveRuntimeAssets(): Plugin {
// stable across HTML revisions.
const basePath = process.env.VITE_BASE_PATH || './';
+// The console's build identity (objectui#12970): `VITE_APP_VERSION` from the
+// build's environment when it is set (a CI or Docker build passes the objectui
+// commit), else the short sha of `HEAD` read from git right here, so a local
+// build names its commit too. Baked as `import.meta.env.VITE_APP_VERSION` — the
+// one spelling `@object-ui/app-shell`'s `buildIdentity.ts` reads for both the
+// help menu's About dialog and Sentry's release fallback. With neither (a build
+// context without `.git`), nothing is baked and the dialog says the build is
+// unknown. `rev-parse` answers in a shallow clone and on a detached HEAD alike.
+function readGitShortSha(): string {
+ try {
+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
+ cwd: import.meta.dirname,
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'ignore'],
+ }).trim();
+ } catch {
+ return '';
+ }
+}
+const consoleBuild = process.env.VITE_APP_VERSION?.trim() || readGitShortSha();
+
// On Vercel/CI we skip the compression and visualizer plugins because the
// Vercel CDN handles gzip/brotli automatically and bundle analysis is not
// needed during CI builds. This reduces peak memory by ~1.5 GB.
@@ -832,6 +854,7 @@ export default defineConfig({
'process.env': {},
'process.platform': '"browser"',
'process.version': '"0.0.0"',
+ ...(consoleBuild ? { 'import.meta.env.VITE_APP_VERSION': JSON.stringify(consoleBuild) } : {}),
},
plugins: [
diff --git a/packages/app-shell/src/__tests__/buildIdentity-12970.test.ts b/packages/app-shell/src/__tests__/buildIdentity-12970.test.ts
new file mode 100644
index 0000000000..c6607116ad
--- /dev/null
+++ b/packages/app-shell/src/__tests__/buildIdentity-12970.test.ts
@@ -0,0 +1,64 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The console's build identity is ONE read (objectui#12970): the help menu's
+ * About dialog and Sentry's release fallback both go through
+ * `resolveConsoleBuild`, so the two can never name different builds.
+ *
+ * Pinned through the pure readers because this repo's Vitest setup cannot put
+ * a `VITE_*` value on `import.meta.env` (the same reason `resolveSentryGate`
+ * is pure; see its header in `observability/sentry.ts`).
+ */
+
+import { describe, it, expect } from 'vitest';
+import { resolveConsoleBuild, getConsoleBuild } from '../buildIdentity';
+import { resolveSentryRelease } from '../observability/sentry';
+
+describe('resolveConsoleBuild: the console build identity (objectui#12970)', () => {
+ it('names the commit the build baked into VITE_APP_VERSION', () => {
+ expect(resolveConsoleBuild({ VITE_APP_VERSION: '0707b4ae' })).toBe('0707b4ae');
+ });
+
+ it('trims surrounding whitespace from the baked value', () => {
+ expect(resolveConsoleBuild({ VITE_APP_VERSION: ' 0707b4ae\n' })).toBe('0707b4ae');
+ });
+
+ it.each<[string, unknown]>([
+ ['absent', undefined],
+ ['empty', ''],
+ ['whitespace only', ' '],
+ ['not a string', 42],
+ ])('answers null when the value is %s, so the dialog states the fallback', (_label, value) => {
+ expect(resolveConsoleBuild({ VITE_APP_VERSION: value })).toBeNull();
+ });
+
+ it('answers null for a missing env object', () => {
+ expect(resolveConsoleBuild(undefined)).toBeNull();
+ expect(resolveConsoleBuild(null)).toBeNull();
+ });
+
+ it('reads null under this test setup, which bakes no VITE_APP_VERSION', () => {
+ expect(getConsoleBuild()).toBeNull();
+ });
+});
+
+describe('resolveSentryRelease: falls back to the same build identity (objectui#12970)', () => {
+ it('prefers an explicit VITE_SENTRY_RELEASE', () => {
+ expect(resolveSentryRelease({ VITE_SENTRY_RELEASE: 'rel-1', VITE_APP_VERSION: '0707b4ae' })).toBe('rel-1');
+ });
+
+ it('falls back to the value resolveConsoleBuild reads, not a second spelling', () => {
+ for (const env of [
+ { VITE_APP_VERSION: '0707b4ae' },
+ { VITE_APP_VERSION: ' 0707b4ae ' },
+ { VITE_SENTRY_RELEASE: '', VITE_APP_VERSION: 'be076dad' },
+ ]) {
+ expect(resolveSentryRelease(env)).toBe(resolveConsoleBuild(env));
+ }
+ });
+
+ it("answers 'unknown' when the build named neither", () => {
+ expect(resolveSentryRelease({})).toBe('unknown');
+ expect(resolveSentryRelease({ VITE_SENTRY_RELEASE: ' ', VITE_APP_VERSION: '' })).toBe('unknown');
+ });
+});
diff --git a/packages/app-shell/src/buildIdentity.ts b/packages/app-shell/src/buildIdentity.ts
new file mode 100644
index 0000000000..2d359e0ece
--- /dev/null
+++ b/packages/app-shell/src/buildIdentity.ts
@@ -0,0 +1,49 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The console's own build identity — which objectui commit this bundle was
+ * compiled from (objectui#12970).
+ *
+ * ONE read, shared: the help menu's About dialog shows it, and Sentry tags its
+ * events with it when no `VITE_SENTRY_RELEASE` is set. There is no second
+ * spelling of the value anywhere in the shell.
+ *
+ * Where the value comes from — a property of the BUILD, never of the runtime
+ * (a server cannot know which console bundle it is serving):
+ *
+ * - `VITE_APP_VERSION` in the environment of the build that compiles the app.
+ * This package ships `tsc` output, so its `dist/` still reads
+ * `import.meta.env` and the APP's Vite build replaces it.
+ * - The console app's Vite config (`apps/console/vite.config.ts`) bakes the
+ * variable when it is set, and otherwise the short sha of `HEAD` read from
+ * git at build time, so a local build names its commit too.
+ * - Built with neither, the value is absent and this module answers `null`;
+ * the dialog then says so in words rather than showing an empty field.
+ *
+ * @module
+ */
+
+/** The slice of `import.meta.env` this module reads. */
+export interface BuildIdentityEnv {
+ readonly VITE_APP_VERSION?: unknown;
+}
+
+/**
+ * The build identity carried by `env`, or `null` when the build named none.
+ *
+ * A pure function of its input, split out from {@link getConsoleBuild} for the
+ * same reason `resolveSentryGate` is: this repo's Vitest setup cannot put a
+ * `VITE_*` value on `import.meta.env`, so the reading is pinned through this.
+ * Only a non-empty string counts; whitespace is trimmed.
+ */
+export function resolveConsoleBuild(env: BuildIdentityEnv | null | undefined): string | null {
+ const raw = env?.VITE_APP_VERSION;
+ if (typeof raw !== 'string') return null;
+ const value = raw.trim();
+ return value === '' ? null : value;
+}
+
+/** The build identity this bundle was compiled with, or `null`. */
+export function getConsoleBuild(): string | null {
+ return resolveConsoleBuild((import.meta as { env?: BuildIdentityEnv }).env);
+}
diff --git a/packages/app-shell/src/layout/AboutDialog.tsx b/packages/app-shell/src/layout/AboutDialog.tsx
new file mode 100644
index 0000000000..0055502177
--- /dev/null
+++ b/packages/app-shell/src/layout/AboutDialog.tsx
@@ -0,0 +1,62 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * AboutDialog — the help menu's About entry (objectui#12970).
+ *
+ * Names the build this console was compiled from, so a reviewer can tell which
+ * objectui build a deployment serves. The value is the shared build identity
+ * (`buildIdentity.ts`), the same read Sentry's release falls back to. A console
+ * built without one says so in words; the field is never left empty.
+ *
+ * The runtime's own build identity is deliberately not shown here: the shell
+ * has no request that reads it today, and adding one is outside this dialog.
+ * @module
+ */
+
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from '@object-ui/components';
+import { useObjectTranslation } from '@object-ui/i18n';
+
+export interface AboutDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** The console's build identity, or `null` when the build named none. */
+ build: string | null;
+}
+
+export function AboutDialog({ open, onOpenChange, build }: AboutDialogProps) {
+ const { t } = useObjectTranslation();
+ return (
+
+ );
+}
diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx
index 4dbce5f187..b1af036b0a 100644
--- a/packages/app-shell/src/layout/AppHeader.tsx
+++ b/packages/app-shell/src/layout/AppHeader.tsx
@@ -69,6 +69,7 @@ import {
Keyboard,
Pencil,
MoreHorizontal,
+ Info,
} from 'lucide-react';
import { useState, useEffect, useLayoutEffect, useCallback, useRef, type RefObject } from 'react';
@@ -107,6 +108,8 @@ import { resolveViewId } from '../utils/resolveViewId.js';
import { getProductName, getLogoUrl } from '../runtime-config.js';
import { LocalizedSidebarTrigger } from './LocalizedSidebarTrigger.js';
import { PreviewBadge } from './PreviewBadge.js';
+import { AboutDialog } from './AboutDialog.js';
+import { getConsoleBuild } from '../buildIdentity.js';
import { GO_HOME_SHORTCUT, useAdvertiseShortcut, type ShortcutChord } from '../chrome/advertisedShortcuts.js';
function humanizeSlug(slug: string): string {
@@ -443,6 +446,9 @@ export function AppHeader({
// Click-reachable entry for the keyboard-shortcuts dialog (was `?`-key only).
// Shares the `?shortcuts=1` URL param with KeyboardShortcutsDialog (C2/C3).
const { openOverlay: openShortcuts } = useUrlOverlay(KEYBOARD_SHORTCUTS_PARAM);
+ // The help menu's About dialog (objectui#12970): ephemeral, so component
+ // state, not a URL param — nobody shares a link to it.
+ const [aboutOpen, setAboutOpen] = useState(false);
const {
user,
signOut,
@@ -1137,8 +1143,19 @@ export function AppHeader({
{t('help.onlineDocs', { defaultValue: 'Online documentation' })}
+ {/* About — names the console's own build, on every variant
+ (objectui#12970). */}
+ setAboutOpen(true)}
+ >
+
+ {t('help.about', { defaultValue: 'About' })}
+
+
{/* Group 3: Account (theme + lang moved into avatar dropdown) */}
diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.aboutBuild-12970.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.aboutBuild-12970.test.tsx
new file mode 100644
index 0000000000..0544a15bac
--- /dev/null
+++ b/packages/app-shell/src/layout/__tests__/AppHeader.aboutBuild-12970.test.tsx
@@ -0,0 +1,256 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The help menu's About entry (objectui#12970): on every `AppHeader` variant —
+ * the tenant console's `app` bar and the control plane's `home` / `orgs` /
+ * `studio` bars are the one component — the help menu carries 「关于」 / About,
+ * and the dialog it opens names the build this console was compiled from.
+ *
+ * The build identity is `buildIdentity.ts`'s one read, stubbed here through a
+ * mutable reading: this repo's Vitest setup cannot put a `VITE_*` value on
+ * `import.meta.env`, and the pure reader is pinned on its own in
+ * `buildIdentity-12970.test.ts`.
+ *
+ * `t` answers `«key»` and drops `defaultValue`, so a label written as a
+ * literal cannot pass for one read from the packs.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent, within } from '@testing-library/react';
+import React from 'react';
+import { MemoryRouter, Routes, Route } from 'react-router-dom';
+import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions';
+
+vi.mock('@object-ui/i18n', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useObjectTranslation: () => ({
+ language: 'en',
+ t: (key: string) => `«${key}»`,
+ }),
+ useObjectLabel: () => {
+ const label = (x: { name?: string; label?: string } | string) =>
+ typeof x === 'string' ? x : String(x?.label ?? x?.name ?? '');
+ return {
+ objectLabel: label,
+ objectPluralLabel: label,
+ dashboardLabel: label,
+ pageLabel: label,
+ reportLabel: label,
+ viewLabel: (_o: string, _v: string, fallback?: string) => fallback ?? '',
+ appLabel: label,
+ };
+ },
+}));
+
+/**
+ * Passthrough menu primitives: Radix keeps a closed menu unmounted, so the
+ * help menu's CONTENT renders its children here. The dialog is the real one.
+ */
+vi.mock('@object-ui/components', async (importOriginal) => {
+ const actual = await importOriginal>();
+ const strip = (p: any) => {
+ const { asChild, variant, size, align, sideOffset, ...rest } = p ?? {};
+ return rest;
+ };
+ const Pass = ({ children, ...p }: any) =>