diff --git a/CHANGELOG.md b/CHANGELOG.md index 721a80f..cbecf14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Added +- In-app build stamp: the build bakes `v ()` into + `dist/sql.html` (graceful `v` fallback when not a git checkout) and + shows it in the user menu, so a bug report can be tied to an exact build (#74). - `NOTICE` + `THIRD-PARTY-NOTICES.md`, and the bundled Chart.js / dagre (MIT) notices are now embedded in the built `dist/sql.html`. - `CONTRIBUTING.md` and this `CHANGELOG.md`. diff --git a/build/build.mjs b/build/build.mjs index fb3916f..c513761 100644 --- a/build/build.mjs +++ b/build/build.mjs @@ -8,12 +8,32 @@ import { build } from 'esbuild'; import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, '..'); +// The build stamp shown in the UI (user menu) and grep-able in dist/sql.html, so +// a bug report can be tied to an exact build: `v ()`, or +// just `v` when this isn't a git checkout (offline tarball, CI export). +// A dirty working tree appends `-dirty` so a hand-built artifact (e.g. a manual +// `kubectl cp dist/sql.html`) is never mistaken for the clean commit it sits on. +// Version source: $ASB_VERSION when set (bundle.sh passes the release tag so the +// stamp and the bundle's VERSION file stay in lockstep), else package.json. +async function buildStamp() { + const version = process.env.ASB_VERSION + || JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8')).version; + let commit = ''; + try { + commit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root }).toString().trim(); + // `git status --porcelain` is empty iff the tree exactly matches HEAD. + if (execFileSync('git', ['status', '--porcelain'], { cwd: root }).toString().trim()) commit += '-dirty'; + } catch { /* not a git checkout — fall back to version only */ } + return commit ? `v${version} (${commit})` : `v${version}`; +} + async function main() { const result = await build({ entryPoints: [resolve(root, 'src/main.js')], @@ -24,7 +44,11 @@ async function main() { write: false, legalComments: 'none', }); - const script = result.outputFiles[0].text; + // Replace the `__ASB_BUILD__` placeholder (a string literal in src/main.js) + // with the build stamp before the bundle is inlined — same token-replace seam + // as the styles/script splices below. replaceAll is robust to either quote + // style minify may emit around the literal. + const script = result.outputFiles[0].text.replaceAll('__ASB_BUILD__', await buildStamp()); const styles = await readFile(resolve(root, 'src/styles.css'), 'utf8'); const template = await readFile(resolve(here, 'template.html'), 'utf8'); diff --git a/build/bundle.sh b/build/bundle.sh index 537ae1e..7489317 100755 --- a/build/bundle.sh +++ b/build/bundle.sh @@ -22,7 +22,9 @@ OUT="$ROOT/dist" STAGE="$OUT/bundle/altinity-sql-browser" echo "==> Building SPA" -node "$ROOT/build/build.mjs" +# Pass the resolved version through so the in-HTML build stamp matches the +# VERSION file written below (build.mjs honors $ASB_VERSION over package.json). +ASB_VERSION="$VERSION" node "$ROOT/build/build.mjs" echo "==> Staging bundle ($VERSION)" rm -rf "$OUT/bundle" diff --git a/src/main.js b/src/main.js index 945c35f..2dd0a13 100644 --- a/src/main.js +++ b/src/main.js @@ -87,7 +87,7 @@ export async function bootstrap(app, env) { /* c8 ignore start -- browser entry side-effect, exercised via the live app */ if (typeof document !== 'undefined' && !globalThis.__ASB_NO_AUTOSTART__) { - const app = createApp({ Chart, Dagre }); + const app = createApp({ Chart, Dagre, build: '__ASB_BUILD__' }); document.addEventListener('keydown', (e) => handleKeydown(e, app)); bootstrap(app, { location: window.location, diff --git a/src/styles.css b/src/styles.css index 75cc72b..e6190a1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -295,6 +295,11 @@ body { .user-menu .um-item:hover { background: var(--bg-hover); } .user-menu .um-item.danger { color: #ef4444; } .user-menu .um-item.danger:hover { background: color-mix(in oklab, #ef4444 12%, transparent); } +.user-menu .um-build { + margin-top: 3px; padding: 7px 9px; border-top: 1px solid var(--border-faint); + font-size: 10.5px; color: var(--fg-faint); font-family: var(--mono); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} /* ------------ header File menu + library title ------------ */ .hd-divider { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; } diff --git a/src/ui/app.js b/src/ui/app.js index 1a6507a..0f5bacf 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -64,6 +64,10 @@ export function createApp(env = {}) { // child tab can inline the page's CSS (about:blank ships none of it). openWindow: env.openWindow || ((...a) => win.open(...a)), stylesText: env.stylesText || (doc.querySelector('style') ? doc.querySelector('style').textContent : ''), + // Build stamp ("v0.1.4 (abc1234)") injected at build time via main.js; shown + // in the user menu so a bug report can be tied to a build. 'dev' in tests / + // an un-built run where the placeholder was never replaced. + build: env.build || 'dev', }; // Two ways to be signed in: OAuth (a JWT bearer, the default) or 'basic' — @@ -787,7 +791,8 @@ export function createApp(env = {}) { let close; const menu = h('div', { class: 'user-menu' }, h('div', { class: 'um-id' }, app.email()), - h('button', { class: 'um-item danger', onclick: () => { close(); app.signOut(); } }, Icon.logout(), h('span', null, 'Log out'))); + h('button', { class: 'um-item danger', onclick: () => { close(); app.signOut(); } }, Icon.logout(), h('span', null, 'Log out')), + h('div', { class: 'um-build', title: 'App version / build' }, app.build)); ({ close } = anchoredPopover(menu, app.dom.userBtn, 'userMenu')); } app.openUserMenu = openUserMenu; diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index b38fcad..f0c3d2c 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -31,6 +31,7 @@ export function makeApp(over = {}) { cssVar: () => '', // blank → chartColors() uses its dark-theme fallbacks chart: null, host: () => 'test.host', + build: 'v0.0.0-test', activeTab: () => activeTab(state), isSignedIn: () => true, email: () => 'me@example.com', diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index fe78d8f..4aed1bd 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -135,6 +135,7 @@ describe('renderApp shell', () => { const menu = document.querySelector('.user-menu'); expect(menu).not.toBeNull(); expect(menu.querySelector('.um-id').textContent).toBe('me@example.com'); + expect(menu.querySelector('.um-build').textContent).toBe(app.build); // build stamp ('dev' here) menu.querySelector('.um-item.danger').dispatchEvent(new Event('click', { bubbles: true })); expect(app.token).toBeNull(); expect(e.sessionStorage.getItem('oauth_id_token')).toBeNull();