diff --git a/Dockerfile b/Dockerfile index 39a95f9..1146815 100755 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/backend/src/app.ts b/backend/src/app.ts index a0027da..5c3ccd9 100755 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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); diff --git a/backend/src/buildInfo.ts b/backend/src/buildInfo.ts new file mode 100644 index 0000000..bc53067 --- /dev/null +++ b/backend/src/buildInfo.ts @@ -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/` 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 ` ` 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/`, 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; + 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; +} diff --git a/backend/src/routes/adminVersion.ts b/backend/src/routes/adminVersion.ts new file mode 100644 index 0000000..450855f --- /dev/null +++ b/backend/src/routes/adminVersion.ts @@ -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; diff --git a/backend/src/writeBuildInfo.ts b/backend/src/writeBuildInfo.ts new file mode 100644 index 0000000..ea15e88 --- /dev/null +++ b/backend/src/writeBuildInfo.ts @@ -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}`); +} diff --git a/backend/tests/integration/adminVersion.integration.test.ts b/backend/tests/integration/adminVersion.integration.test.ts new file mode 100644 index 0000000..1347b31 --- /dev/null +++ b/backend/tests/integration/adminVersion.integration.test.ts @@ -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'); + }); +}); diff --git a/backend/tests/unit/buildInfo.test.ts b/backend/tests/unit/buildInfo.test.ts new file mode 100644 index 0000000..fd453fa --- /dev/null +++ b/backend/tests/unit/buildInfo.test.ts @@ -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 { + 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 `^` 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); + }); +}); diff --git a/frontend/src/admin/Admin.tsx b/frontend/src/admin/Admin.tsx index 23a0a44..475548e 100755 --- a/frontend/src/admin/Admin.tsx +++ b/frontend/src/admin/Admin.tsx @@ -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() {
Admin
+
diff --git a/frontend/src/admin/BuildStamp.tsx b/frontend/src/admin/BuildStamp.tsx new file mode 100644 index 0000000..828ac71 --- /dev/null +++ b/frontend/src/admin/BuildStamp.tsx @@ -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(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 ( + + {info.commit} + {built ? ` · built ${built}` : ''} + + ); +}