Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/12970-about-build.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions apps/console/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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: [
Expand Down
64 changes: 64 additions & 0 deletions packages/app-shell/src/__tests__/buildIdentity-12970.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
49 changes: 49 additions & 0 deletions packages/app-shell/src/buildIdentity.ts
Original file line number Diff line number Diff line change
@@ -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);
}
62 changes: 62 additions & 0 deletions packages/app-shell/src/layout/AboutDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="overlay:about">
<DialogHeader>
<DialogTitle>{t('help.aboutTitle', { defaultValue: 'About this console' })}</DialogTitle>
<DialogDescription>
{t('help.aboutDescription', { defaultValue: 'The build this console was compiled from.' })}
</DialogDescription>
</DialogHeader>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 pt-2 text-sm">
<dt className="text-muted-foreground">
{t('help.aboutBuild', { defaultValue: 'Console build' })}
</dt>
<dd data-testid="about:console-build" className="min-w-0 break-all">
{build ? (
<code className="font-mono">{build}</code>
) : (
<span className="text-muted-foreground">
{t('help.aboutBuildUnknown', {
defaultValue: 'Unknown build (no version was set when this console was built)',
})}
</span>
)}
</dd>
</dl>
</DialogContent>
</Dialog>
);
}
17 changes: 17 additions & 0 deletions packages/app-shell/src/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
Keyboard,
Pencil,
MoreHorizontal,
Info,
} from 'lucide-react';

import { useState, useEffect, useLayoutEffect, useCallback, useRef, type RefObject } from 'react';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1137,8 +1143,19 @@ export function AppHeader({
{t('help.onlineDocs', { defaultValue: 'Online documentation' })}
</a>
</DropdownMenuItem>
{/* About — names the console's own build, on every variant
(objectui#12970). */}
<DropdownMenuItem
className="cursor-pointer"
data-testid="action:about:open"
onClick={() => setAboutOpen(true)}
>
<Info className="mr-2 h-4 w-4" />
{t('help.about', { defaultValue: 'About' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AboutDialog open={aboutOpen} onOpenChange={setAboutOpen} build={getConsoleBuild()} />
</div>

{/* Group 3: Account (theme + lang moved into avatar dropdown) */}
Expand Down
Loading
Loading