Show the deployed build version in the app

Bakes the git short commit hash into the client bundle at build time and
exposes the same info from the server via GET /api/version. The header
displays the loaded build's commit and flags when it no longer matches
what the server is running, so a stale open tab is easy to spot after a
deploy. The Docker build now includes .git so the hash can be resolved
during the image build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Claudio Schaad 2026-07-30 12:12:30 +02:00
parent 0b73929fea
commit b4ed4812fc
13 changed files with 117 additions and 2 deletions

View file

@ -2,7 +2,6 @@ node_modules
**/node_modules **/node_modules
**/dist **/dist
**/*.tsbuildinfo **/*.tsbuildinfo
.git
.tools .tools
data data
data-dev data-dev

View file

@ -1,6 +1,7 @@
# ---- Stage 1: build shared + client (static assets) ---- # ---- Stage 1: build shared + client (static assets) ----
FROM node:20-bookworm-slim AS build-client FROM node:20-bookworm-slim AS build-client
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
COPY package.json ./ COPY package.json ./
COPY shared/package.json shared/package.json COPY shared/package.json shared/package.json
COPY server/package.json server/package.json COPY server/package.json server/package.json
@ -8,11 +9,13 @@ COPY client/package.json client/package.json
RUN npm install RUN npm install
COPY shared shared COPY shared shared
COPY client client COPY client client
COPY .git .git
RUN npm run build -w shared && npm run build -w client RUN npm run build -w shared && npm run build -w client
# ---- Stage 2: build shared + server (compiled JS) ---- # ---- Stage 2: build shared + server (compiled JS) ----
FROM node:20-bookworm-slim AS build-server FROM node:20-bookworm-slim AS build-server
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
COPY package.json ./ COPY package.json ./
COPY shared/package.json shared/package.json COPY shared/package.json shared/package.json
COPY server/package.json server/package.json COPY server/package.json server/package.json
@ -20,6 +23,7 @@ COPY client/package.json client/package.json
RUN npm install RUN npm install
COPY shared shared COPY shared shared
COPY server server COPY server server
COPY .git .git
RUN npm run build -w shared && npm run build -w server RUN npm run build -w shared && npm run build -w server
# ---- Stage 3: runtime ---- # ---- Stage 3: runtime ----

View file

@ -0,0 +1,6 @@
import type { AppVersion } from '@citytracker/shared';
import { api } from './client';
export function fetchVersion() {
return api.get<AppVersion>('/version');
}

View file

@ -1,5 +1,6 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { AppVersion } from './AppVersion';
export function AppShell() { export function AppShell() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
@ -14,6 +15,7 @@ export function AppShell() {
<div className="app-shell"> <div className="app-shell">
<header className="app-header"> <header className="app-header">
<span className="app-title">CityTracker</span> <span className="app-title">CityTracker</span>
<AppVersion />
<nav className="app-nav"> <nav className="app-nav">
<NavLink to="/" end> <NavLink to="/" end>
Map Map

View file

@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import type { AppVersion as AppVersionInfo } from '@citytracker/shared';
import { fetchVersion } from '../../api/version';
const clientVersion = __APP_VERSION__;
export function AppVersion() {
const [serverVersion, setServerVersion] = useState<AppVersionInfo | null>(null);
useEffect(() => {
fetchVersion()
.then(setServerVersion)
.catch(() => undefined);
}, []);
const stale = serverVersion !== null && serverVersion.commit !== clientVersion.commit;
const title = [
`Loaded build: ${clientVersion.commit} (${clientVersion.builtAt})`,
serverVersion
? `Server build: ${serverVersion.commit} (${serverVersion.builtAt})`
: 'Server build: checking…',
].join('\n');
return (
<span className={`app-version${stale ? ' app-version-stale' : ''}`} title={title}>
{clientVersion.commit}
{stale && ' — reload for latest'}
</span>
);
}

View file

@ -135,6 +135,16 @@ a {
font-size: 1.1rem; font-size: 1.1rem;
} }
.app-version {
font-size: 0.75rem;
color: var(--color-text-muted);
font-family: monospace;
}
.app-version-stale {
color: var(--color-primary);
}
.app-nav { .app-nav {
display: flex; display: flex;
gap: var(--spacing-3); gap: var(--spacing-3);

View file

@ -1,2 +1,4 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" /> /// <reference types="vite-plugin-pwa/react" />
declare const __APP_VERSION__: { commit: string; builtAt: string };

View file

@ -1,8 +1,20 @@
import { execSync } from 'node:child_process';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from 'vite-plugin-pwa';
function getCommit(): string {
try {
return execSync('git rev-parse --short HEAD').toString().trim();
} catch {
return 'dev';
}
}
export default defineConfig({ export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify({ commit: getCommit(), builtAt: new Date().toISOString() }),
},
plugins: [ plugins: [
react(), react(),
VitePWA({ VitePWA({

View file

@ -5,7 +5,7 @@
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc && node -e \"require('fs').cpSync('src/db/migrations','dist/db/migrations',{recursive:true})\"", "build": "tsc && node -e \"require('fs').cpSync('src/db/migrations','dist/db/migrations',{recursive:true})\" && node scripts/write-version.js",
"start": "node dist/index.js", "start": "node dist/index.js",
"clean": "rimraf dist" "clean": "rimraf dist"
}, },

View file

@ -0,0 +1,20 @@
const { execSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
function getCommit() {
try {
return execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim();
} catch {
return 'dev';
}
}
const version = {
commit: getCommit(),
builtAt: new Date().toISOString(),
};
const outDir = path.join(__dirname, '..', 'dist');
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'version.json'), JSON.stringify(version));

View file

@ -15,6 +15,8 @@ export function createApp() {
app.use(express.json()); app.use(express.json());
app.use(sessionMiddleware); app.use(sessionMiddleware);
app.get('/api/version', (req, res) => res.json(config.version));
app.use('/api/auth', authRouter); app.use('/api/auth', authRouter);
app.use('/api/users', usersRouter); app.use('/api/users', usersRouter);
app.use('/api/cities', citiesRouter); app.use('/api/cities', citiesRouter);

View file

@ -1,11 +1,32 @@
import path from 'node:path'; import path from 'node:path';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import fs from 'node:fs';
import { execSync } from 'node:child_process';
import type { AppVersion } from '@citytracker/shared';
function readBool(value: string | undefined, fallback: boolean): boolean { function readBool(value: string | undefined, fallback: boolean): boolean {
if (value === undefined) return fallback; if (value === undefined) return fallback;
return value.toLowerCase() === 'true' || value === '1'; return value.toLowerCase() === 'true' || value === '1';
} }
// In the Docker image this file is written at build time by scripts/write-version.js and
// sits next to this compiled file in dist/. Under `tsx` in local dev there's no build step,
// so fall back to asking git directly for the current commit.
function readVersion(): AppVersion {
try {
const raw = fs.readFileSync(path.join(__dirname, 'version.json'), 'utf-8');
return JSON.parse(raw) as AppVersion;
} catch {
let commit = 'dev';
try {
commit = execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim();
} catch {
// no .git available either (e.g. a source-only deploy) — leave commit as 'dev'
}
return { commit, builtAt: new Date().toISOString() };
}
}
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
let sessionSecret = process.env.SESSION_SECRET; let sessionSecret = process.env.SESSION_SECRET;
@ -34,4 +55,5 @@ export const config = {
// file's location, not process.cwd(), since cwd depends on how the process was launched. // file's location, not process.cwd(), since cwd depends on how the process was launched.
clientDistDir: clientDistDir:
process.env.CLIENT_DIST_DIR || path.join(__dirname, '..', '..', 'client', 'dist'), process.env.CLIENT_DIST_DIR || path.join(__dirname, '..', '..', 'client', 'dist'),
version: readVersion(),
}; };

View file

@ -47,3 +47,8 @@ export interface GeocodeResult {
export interface SetupStatus { export interface SetupStatus {
needsSetup: boolean; needsSetup: boolean;
} }
export interface AppVersion {
commit: string;
builtAt: string;
}