Merge pull request 'feat(admin): show the deployed commit and build time in the admin (#233)' (#234) from feature/233-admin-version-stamp into main
Reviewed-on: #234
This commit was merged in pull request #234.
This commit is contained in:
+13
-1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user