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:
parent
0b73929fea
commit
b4ed4812fc
13 changed files with 117 additions and 2 deletions
|
|
@ -2,7 +2,6 @@ node_modules
|
|||
**/node_modules
|
||||
**/dist
|
||||
**/*.tsbuildinfo
|
||||
.git
|
||||
.tools
|
||||
data
|
||||
data-dev
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# ---- Stage 1: build shared + client (static assets) ----
|
||||
FROM node:20-bookworm-slim AS build-client
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json ./
|
||||
COPY shared/package.json shared/package.json
|
||||
COPY server/package.json server/package.json
|
||||
|
|
@ -8,11 +9,13 @@ COPY client/package.json client/package.json
|
|||
RUN npm install
|
||||
COPY shared shared
|
||||
COPY client client
|
||||
COPY .git .git
|
||||
RUN npm run build -w shared && npm run build -w client
|
||||
|
||||
# ---- Stage 2: build shared + server (compiled JS) ----
|
||||
FROM node:20-bookworm-slim AS build-server
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json ./
|
||||
COPY shared/package.json shared/package.json
|
||||
COPY server/package.json server/package.json
|
||||
|
|
@ -20,6 +23,7 @@ COPY client/package.json client/package.json
|
|||
RUN npm install
|
||||
COPY shared shared
|
||||
COPY server server
|
||||
COPY .git .git
|
||||
RUN npm run build -w shared && npm run build -w server
|
||||
|
||||
# ---- Stage 3: runtime ----
|
||||
|
|
|
|||
6
client/src/api/version.ts
Normal file
6
client/src/api/version.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { AppVersion } from '@citytracker/shared';
|
||||
import { api } from './client';
|
||||
|
||||
export function fetchVersion() {
|
||||
return api.get<AppVersion>('/version');
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { AppVersion } from './AppVersion';
|
||||
|
||||
export function AppShell() {
|
||||
const { user, logout } = useAuth();
|
||||
|
|
@ -14,6 +15,7 @@ export function AppShell() {
|
|||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
<span className="app-title">CityTracker</span>
|
||||
<AppVersion />
|
||||
<nav className="app-nav">
|
||||
<NavLink to="/" end>
|
||||
Map
|
||||
|
|
|
|||
31
client/src/components/layout/AppVersion.tsx
Normal file
31
client/src/components/layout/AppVersion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -135,6 +135,16 @@ a {
|
|||
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 {
|
||||
display: flex;
|
||||
gap: var(--spacing-3);
|
||||
|
|
|
|||
2
client/src/vite-env.d.ts
vendored
2
client/src/vite-env.d.ts
vendored
|
|
@ -1,2 +1,4 @@
|
|||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/react" />
|
||||
|
||||
declare const __APP_VERSION__: { commit: string; builtAt: string };
|
||||
|
|
|
|||
|
|
@ -1,8 +1,20 @@
|
|||
import { execSync } from 'node:child_process';
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
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({
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify({ commit: getCommit(), builtAt: new Date().toISOString() }),
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"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",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
|
|
|
|||
20
server/scripts/write-version.js
Normal file
20
server/scripts/write-version.js
Normal 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));
|
||||
|
|
@ -15,6 +15,8 @@ export function createApp() {
|
|||
app.use(express.json());
|
||||
app.use(sessionMiddleware);
|
||||
|
||||
app.get('/api/version', (req, res) => res.json(config.version));
|
||||
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/cities', citiesRouter);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,32 @@
|
|||
import path from 'node:path';
|
||||
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 {
|
||||
if (value === undefined) return fallback;
|
||||
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';
|
||||
|
||||
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.
|
||||
clientDistDir:
|
||||
process.env.CLIENT_DIST_DIR || path.join(__dirname, '..', '..', 'client', 'dist'),
|
||||
version: readVersion(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -47,3 +47,8 @@ export interface GeocodeResult {
|
|||
export interface SetupStatus {
|
||||
needsSetup: boolean;
|
||||
}
|
||||
|
||||
export interface AppVersion {
|
||||
commit: string;
|
||||
builtAt: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue