feat(admin): show the deployed commit and build time in the admin (#233)
There was no way to tell which build an environment was running. That is not hypothetical: minutes after #232 merged, `npm run backfill:images` in QA failed with `tsx: not found` because the container was still serving the pre-merge image, and the only thing that revealed it was npm echoing the 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.
The header now reads something like `a5076cc · built 29 Aug 20:36`. The commit answers "is this the code I expect"; the build time answers "did my redeploy actually rebuild", which is a different question and the one that would have caught the case above.
The commit is read out of `.git` directly rather than by shelling out, because node:20-bookworm-slim has no git binary and adding an apt layer so the image can print seven characters is a poor trade. `.git` is copied into the build stage only — verified absent from the final image — so no repository history reaches a deployed container.
Resolution is pure and separately tested across every shape that actually occurs: a detached HEAD holding the object name, which is what a checkout of a ref produces; a symbolic HEAD followed to a loose ref file; the same followed to packed-refs, which is what a fresh clone commonly has; peeled `^` tag lines ignored so an annotated tag cannot yield the wrong commit; and every failure path returning `unknown`. That last part is the one that matters most — this runs during a Docker build, and a version stamp must never be the thing that stops a deploy.
Served from a gated /api/admin/version rather than folded into /api/config. That endpoint is public, and a commit hash there would tell any storefront visitor exactly which revision of a public repository is deployed. An integration test asserts the gate and asserts the public config does not carry it, because the boundary is the whole point rather than an implementation detail.
Verified in the built image rather than argued: the stamp inside it reads a5076cc, matching `git rev-parse --short HEAD`, and a running container serves it from /api/admin/version while /api/config returns only what it did before.
Backend: 296 unit, 263 integration, tsc clean, lint unchanged at six pre-existing warnings. Frontend builds clean with its two pre-existing warnings untouched.
Closes #233
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user