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
|
**/node_modules
|
||||||
**/dist
|
**/dist
|
||||||
**/*.tsbuildinfo
|
**/*.tsbuildinfo
|
||||||
.git
|
|
||||||
.tools
|
.tools
|
||||||
data
|
data
|
||||||
data-dev
|
data-dev
|
||||||
|
|
|
||||||
|
|
@ -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 ----
|
||||||
|
|
|
||||||
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 { 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
|
||||||
|
|
|
||||||
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;
|
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);
|
||||||
|
|
|
||||||
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/client" />
|
||||||
/// <reference types="vite-plugin-pwa/react" />
|
/// <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 { 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({
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
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(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);
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -47,3 +47,8 @@ export interface GeocodeResult {
|
||||||
export interface SetupStatus {
|
export interface SetupStatus {
|
||||||
needsSetup: boolean;
|
needsSetup: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppVersion {
|
||||||
|
commit: string;
|
||||||
|
builtAt: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue