feat(admin): show the deployed commit and build time in the admin (#233) #234

Merged
bermudalamb merged 1 commits from feature/233-admin-version-stamp into main 2026-08-29 15:45:56 -05:00
9 changed files with 499 additions and 1 deletions
Showing only changes of commit 44328d0b5c - Show all commits
+13 -1
View File
@@ -10,7 +10,19 @@ WORKDIR /app/backend
COPY backend/package.json ./
RUN npm install
COPY backend/ ./
RUN npm run build
# Only this stage ever sees .git, and the final image below copies just `dist`,
# so no repository history reaches the deployed container. The build reads the
# commit out of it directly rather than shelling out to git, because this base
# image has no git binary and adding an apt layer so the image can print seven
# characters is a poor trade. See #233.
#
# There is no .dockerignore, so .git is in the build context here and in
# Portainer, which clones the repository before building.
COPY .git ./.git
# writeBuildInfo runs against the compiled output, so it has to follow tsc. It
# warns rather than fails when .git is unreadable: a deploy must never be
# stopped by the thing whose only job is to say which deploy it is.
RUN npm run build && node dist/writeBuildInfo.js
FROM node:20-bookworm-slim
WORKDIR /app
+2
View File
@@ -9,6 +9,7 @@ import adminSettingsRouter from './routes/adminSettings';
import adminEmailTemplatesRouter from './routes/adminEmailTemplates';
import adminCategoriesRouter from './routes/adminCategories';
import adminTagsRouter from './routes/adminTags';
import adminVersionRouter from './routes/adminVersion';
import filtersRouter from './routes/filters';
import customersRouter from './routes/customers';
import publicRouter from './routes/public';
@@ -78,6 +79,7 @@ app.use('/api/admin/settings', requireAdminGate, adminSettingsRouter);
app.use('/api/admin/email-templates', requireAdminGate, adminEmailTemplatesRouter);
app.use('/api/admin/categories', requireAdminGate, adminCategoriesRouter);
app.use('/api/admin/tags', requireAdminGate, adminTagsRouter);
app.use('/api/admin/version', requireAdminGate, adminVersionRouter);
app.use('/api/admin', requireAdminGate, adminRouter);
app.use('/api/customers/me/addresses', shippingAddressesRouter);
app.use('/api/customers', customersRouter);
+157
View File
@@ -0,0 +1,157 @@
import { readFileSync } from 'fs';
import path from 'path';
/**
* Which build is running, so a deployed environment can say so.
*
* There was previously no way to tell. On 2026-08-29 a QA container kept
* serving a pre-merge image after its stack was rebuilt, and the only thing
* that revealed it was npm happening to echo an old script line — had the
* change been anywhere other than a package.json script, the container would
* have looked healthy while running the wrong code. See #233.
*
* The commit is read out of `.git` directly rather than by shelling out to
* git: `node:20-bookworm-slim` has no git binary, and adding an apt layer to
* this image so that it can print seven characters is a poor trade.
*
* Everything here fails to `unknown` rather than throwing. This runs during a
* Docker build, and a version stamp must never be the thing that stops a
* deploy.
*/
export const UNKNOWN_COMMIT = 'unknown';
/** Full 40-character object name, which is what both HEAD and refs contain. */
const SHA_PATTERN = /^[0-9a-f]{40}$/;
const SHORT_LENGTH = 7;
/**
* The `.git` files this needs, as content rather than paths.
*
* Passed in rather than read here so the resolution rules are pure and can be
* tested without a repository on disk — the same reasoning `uploadTypes.ts`
* and `keyByCallerAndEmail` are shaped by.
*/
export interface GitSource {
/** `.git/HEAD`, or null when there is no `.git` at all. */
head: string | null;
/** `.git/<ref>` for a symbolic HEAD, or null when the ref is packed. */
readRef(ref: string): string | null;
/** `.git/packed-refs`, or null when the repository has none. */
packedRefs: string | null;
}
/**
* Finds `<sha> <ref>` in a packed-refs file.
*
* Lines beginning `#` are the header and lines beginning `^` are the object an
* annotated tag points at — neither is a ref, and treating a `^` line as one
* would return the wrong commit for any tag.
*/
function fromPackedRefs(packedRefs: string, ref: string): string | null {
for (const line of packedRefs.split('\n')) {
if (line.startsWith('#') || line.startsWith('^')) continue;
const [sha, name] = line.trim().split(/\s+/);
if (name === ref && sha && SHA_PATTERN.test(sha)) return sha;
}
return null;
}
/**
* The short commit for a checkout, or `unknown`.
*
* Two shapes of HEAD are possible and both occur here: a detached HEAD holds
* the object name directly, which is what a checkout of a specific ref
* produces, and a symbolic HEAD holds `ref: refs/heads/<name>`, which is what
* a working clone has. The ref may be loose or packed, and a fresh clone
* commonly packs it.
*/
export function resolveCommit(source: GitSource): string {
const head = source.head?.trim();
if (!head) return UNKNOWN_COMMIT;
if (SHA_PATTERN.test(head)) {
return head.slice(0, SHORT_LENGTH);
}
if (!head.startsWith('ref:')) {
// Neither a ref line nor an object name. Returning it verbatim would put
// whatever the file happened to contain onto the admin screen.
return UNKNOWN_COMMIT;
}
const ref = head.slice('ref:'.length).trim();
if (!ref) return UNKNOWN_COMMIT;
const loose = source.readRef(ref)?.trim();
if (loose && SHA_PATTERN.test(loose)) {
return loose.slice(0, SHORT_LENGTH);
}
const packed = source.packedRefs ? fromPackedRefs(source.packedRefs, ref) : null;
return packed ? packed.slice(0, SHORT_LENGTH) : UNKNOWN_COMMIT;
}
/** Reads a file, treating any failure as absence. */
function readOrNull(filePath: string): string | null {
try {
return readFileSync(filePath, 'utf8');
} catch {
return null;
}
}
/** A `GitSource` backed by a real `.git` directory. */
export function gitSourceAt(gitDir: string): GitSource {
return {
head: readOrNull(path.join(gitDir, 'HEAD')),
// The ref is a repository-relative path with forward slashes; join splits
// it correctly on both platforms.
readRef: (ref) => readOrNull(path.join(gitDir, ...ref.split('/'))),
packedRefs: readOrNull(path.join(gitDir, 'packed-refs'))
};
}
export interface BuildInfo {
commit: string;
builtAt: string | null;
}
/** Where the build writes its stamp, and where the server reads it back. */
export const BUILD_INFO_PATH = path.join(__dirname, 'buildInfo.json');
const MISSING: BuildInfo = { commit: UNKNOWN_COMMIT, builtAt: null };
let cached: BuildInfo | null = null;
/**
* The stamp written at build time.
*
* Read once and cached: it cannot change while the process lives, and this is
* on a request path. Absent in local development, where nothing has been
* built — reported as unknown rather than treated as an error, so `npm run
* dev` is unaffected.
*/
export function readBuildInfo(): BuildInfo {
if (cached) return cached;
const raw = readOrNull(BUILD_INFO_PATH);
if (!raw) {
cached = MISSING;
return cached;
}
try {
const parsed = JSON.parse(raw) as Partial<BuildInfo>;
cached = {
commit: typeof parsed.commit === 'string' ? parsed.commit : UNKNOWN_COMMIT,
builtAt: typeof parsed.builtAt === 'string' ? parsed.builtAt : null
};
} catch {
// A malformed stamp is not worth failing a boot over.
cached = MISSING;
}
return cached;
}
+23
View File
@@ -0,0 +1,23 @@
import { Router, Request, Response } from 'express';
import { readBuildInfo } from '../buildInfo';
const router = Router();
/**
* What build this environment is running (#233).
*
* Behind `requireAdminGate` like every other admin router, and deliberately
* not folded into `/api/config`. That endpoint is public — the storefront
* fetches it on every load — and a commit hash there would tell anyone exactly
* which revision of a public repository is deployed, which is free help to
* someone matching known issues against it. Nothing here is needed by a
* customer.
*
* Not wrapped in asyncRoute because the handler is synchronous: the stamp is
* read from disk once and cached, so there is no promise to reject.
*/
router.get('/', (_req: Request, res: Response) => {
res.json(readBuildInfo());
});
export default router;
+67
View File
@@ -0,0 +1,67 @@
/**
* Writes the build stamp that the admin reads back (#233).
*
* Runs once, at the end of the Docker build, against the compiled output:
*
* npm run build && node dist/writeBuildInfo.js
*
* It has to run after `tsc` because `tsc` writes into `dist/` and would not
* remove a JSON file placed there first — but ordering it explicitly means the
* stamp is never left over from a previous build.
*
* Never fails the build. A missing or unreadable `.git` produces a stamp
* saying `unknown`, which is a worse answer than a commit and a much better
* one than a deploy that stopped. `.git` is absent from the final image by
* design; only this build stage sees it.
*/
import { writeFileSync, existsSync } from 'fs';
import path from 'path';
import { resolveCommit, gitSourceAt, BUILD_INFO_PATH, UNKNOWN_COMMIT, BuildInfo } from './buildInfo';
/**
* Where `.git` is, relative to wherever this was run from.
*
* Two layouts, both real. In the container the repository's `.git` is copied
* beside the backend, so it sits in the working directory. Locally the backend
* is a subdirectory of the repository, so it is one level up. An explicit
* argument wins over both, which is what makes this testable by hand.
*/
function findGitDir(explicit?: string): string | null {
const candidates = [
explicit,
path.join(process.cwd(), '.git'),
path.join(process.cwd(), '..', '.git')
].filter((candidate): candidate is string => typeof candidate === 'string');
return candidates.find((candidate) => existsSync(candidate)) ?? null;
}
export function buildStamp(gitDir: string | null): BuildInfo {
return {
commit: gitDir ? resolveCommit(gitSourceAt(gitDir)) : UNKNOWN_COMMIT,
// Whole seconds: this is read by a person comparing it to when they
// pressed a button, not by anything that needs precision.
builtAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
};
}
// Guarded so that importing this module cannot rewrite the stamp of a running
// deployment — the same reasoning as backfillImageReencode.ts (#231).
if (require.main === module) {
const gitDir = findGitDir(process.argv[2]);
const stamp = buildStamp(gitDir);
if (stamp.commit === UNKNOWN_COMMIT) {
// Loud, because a deploy that cannot say what it is defeats the point of
// the stamp — but a warning, not a failure.
const where = gitDir ? ` at ${gitDir}` : '';
console.warn(
`[build-info] no readable .git found${where}` +
`the admin will report the commit as "${UNKNOWN_COMMIT}"`
);
}
writeFileSync(BUILD_INFO_PATH, `${JSON.stringify(stamp, null, 2)}\n`, 'utf8');
console.info(`[build-info] ${stamp.commit} built ${stamp.builtAt}`);
}
@@ -0,0 +1,65 @@
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
// ADMIN_GATE_SECRET is read per request rather than at import, so it can be set
// here without reloading the app, and is restored afterwards so this file
// cannot change how anything later in the same process behaves. Same shape as
// adminGate.integration.test.ts.
const SECRET = 'integration-version-secret';
const original = process.env.ADMIN_GATE_SECRET;
let warn: jest.SpyInstance;
beforeEach(async () => {
await resetDb();
warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
warn.mockRestore();
if (original === undefined) {
delete process.env.ADMIN_GATE_SECRET;
} else {
process.env.ADMIN_GATE_SECRET = original;
}
});
afterAll(async () => {
await pool.end();
await closeDb();
});
describe('reporting which build is running', () => {
it('returns a commit and a build time', async () => {
const res = await request(app).get('/api/admin/version');
expect(res.status).toBe(200);
expect(typeof res.body.commit).toBe('string');
expect(res.body.commit.length).toBeGreaterThan(0);
// Null under test, where nothing has been built. The contract is that the
// key is always present, so the admin never has to distinguish "absent"
// from "unbuilt".
expect(res.body).toHaveProperty('builtAt');
});
// The whole point is that this is not on public /api/config. A commit hash
// there would tell any visitor which revision of a public repository is
// deployed.
it('is behind the admin gate', async () => {
process.env.ADMIN_GATE_SECRET = SECRET;
const res = await request(app).get('/api/admin/version');
expect(res.status).toBe(403);
expect(res.body.error).toBe('forbidden');
});
it('does not leak the commit through the public config endpoint', async () => {
const res = await request(app).get('/api/config');
expect(res.status).toBe(200);
expect(res.body).not.toHaveProperty('commit');
expect(res.body).not.toHaveProperty('builtAt');
});
});
+109
View File
@@ -0,0 +1,109 @@
import { resolveCommit, GitSource, UNKNOWN_COMMIT } from '../../src/buildInfo';
// A source with nothing in it, so each test states only the files it cares
// about. Every field is deliberately explicit — a missing `.git` is a normal
// case here, not an edge one, and it has to be as easy to write as the others.
function source(overrides: Partial<GitSource> = {}): GitSource {
return {
head: null,
readRef: () => null,
packedRefs: null,
...overrides
};
}
const SHA = '846646f7ec8ec0087ec1d89bcee8ab1865b57a7a';
describe('resolveCommit', () => {
// What a fresh clone at a specific commit looks like — Portainer checks out
// a ref, and a detached HEAD holds the SHA with no indirection at all.
it('reads a detached HEAD directly', () => {
expect(resolveCommit(source({ head: `${SHA}\n` }))).toBe('846646f');
});
// What a working checkout looks like, including this repository.
it('follows a symbolic HEAD to its ref file', () => {
expect(
resolveCommit(
source({
head: 'ref: refs/heads/main\n',
readRef: (ref) => (ref === 'refs/heads/main' ? `${SHA}\n` : null)
})
)
).toBe('846646f');
});
// A ref that has been packed has no loose file. Without this the common case
// of a freshly cloned repository resolves to unknown.
it('falls back to packed-refs when the ref file is absent', () => {
expect(
resolveCommit(
source({
head: 'ref: refs/heads/main\n',
packedRefs: `# pack-refs with: peeled fully-peeled sorted\n${SHA} refs/heads/main\n`
})
)
).toBe('846646f');
});
it('picks the right entry out of a packed-refs with many', () => {
const other = 'ffffffffffffffffffffffffffffffffffffffff';
expect(
resolveCommit(
source({
head: 'ref: refs/heads/main\n',
packedRefs:
`# pack-refs with: peeled fully-peeled sorted\n` +
`${other} refs/heads/other\n` +
`${SHA} refs/heads/main\n` +
`${other} refs/tags/v1\n`
})
)
).toBe('846646f');
});
// Annotated tags write a second `^<sha>` line for the object the tag points
// at. It is not a ref line and must not be mistaken for one.
it('ignores peeled tag lines in packed-refs', () => {
expect(
resolveCommit(
source({
head: 'ref: refs/tags/v1\n',
packedRefs:
`${SHA} refs/tags/v1\n` + `^ffffffffffffffffffffffffffffffffffffffff\n`
})
)
).toBe('846646f');
});
// Every failure below returns UNKNOWN rather than throwing. This runs during
// a Docker build; a version stamp must never be the thing that stops a deploy.
it('is unknown when there is no .git at all', () => {
expect(resolveCommit(source())).toBe(UNKNOWN_COMMIT);
});
it('is unknown when the ref resolves to nothing', () => {
expect(resolveCommit(source({ head: 'ref: refs/heads/main\n' }))).toBe(UNKNOWN_COMMIT);
});
it('is unknown when packed-refs does not mention the ref', () => {
expect(
resolveCommit(
source({
head: 'ref: refs/heads/gone\n',
packedRefs: `${SHA} refs/heads/main\n`
})
)
).toBe(UNKNOWN_COMMIT);
});
// A HEAD that is neither a ref line nor a SHA. Returning it verbatim would
// put arbitrary file contents on the admin screen.
it('is unknown when HEAD is not something it recognises', () => {
expect(resolveCommit(source({ head: 'not a sha and not a ref\n' }))).toBe(UNKNOWN_COMMIT);
});
it('is unknown when HEAD is empty', () => {
expect(resolveCommit(source({ head: ' \n' }))).toBe(UNKNOWN_COMMIT);
});
});
+2
View File
@@ -33,6 +33,7 @@ import Emails from './Emails';
import Settings from './Settings';
import Categories from './Categories';
import Tags from './Tags';
import BuildStamp from './BuildStamp';
import CategoryTreeSelect from './CategoryTreeSelect';
import ItemCard from '../components/ItemCard';
import InventoryFilters from './InventoryFilters';
@@ -379,6 +380,7 @@ export default function Admin() {
<Header className="site-header" style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
<Title level={3} className="site-header-title" style={{ color: token.colorText }}>Admin</Title>
<div className="site-header-actions">
<BuildStamp />
<Switch checked={mode === 'dark'} onChange={toggle} checkedChildren="Dark" unCheckedChildren="Light" />
</div>
</Header>
+61
View File
@@ -0,0 +1,61 @@
import React, { useEffect, useState } from 'react';
import Typography from 'antd/es/typography';
const { Text } = Typography;
interface BuildInfo {
commit: string;
builtAt: string | null;
}
/**
* Which build this environment is running (#233).
*
* There was previously no way to tell from the screen, and a stack that had
* been redeployed could still be serving the previous image. Shown in the admin
* header rather than tucked into a settings tab, because its whole value is
* being visible without going to look for it.
*
* Served from the gated /api/admin/version rather than the public /api/config,
* so a commit hash is never handed to a storefront visitor.
*/
export default function BuildStamp() {
const [info, setInfo] = useState<BuildInfo | null>(null);
useEffect(() => {
let cancelled = false;
void fetch('/api/admin/version')
.then((res) => (res.ok ? res.json() : null))
.then((data: BuildInfo | null) => {
if (!cancelled) setInfo(data);
})
// Renders nothing on failure. A version stamp is a convenience, and it
// should not put an error in front of someone doing something else.
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
if (!info) return null;
// Local time and no seconds: this is read by a person comparing it against
// when they pressed redeploy, not by anything that needs precision.
const built = info.builtAt
? new Date(info.builtAt).toLocaleString(undefined, {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})
: null;
return (
<Text type="secondary" style={{ fontSize: 12, fontFamily: 'monospace' }}>
{info.commit}
{built ? ` · built ${built}` : ''}
</Text>
);
}