1
0
Fork 0

feat(version): expose build/commit metadata via /api/version and overlay

This commit is contained in:
Jason Fraley 2026-08-12 12:38:33 -04:00
parent 3b0d90ea62
commit bbfbd5966c
6 changed files with 192 additions and 11 deletions

3
.gitignore vendored
View file

@ -23,6 +23,9 @@
.DS_Store
*.pem
# generated version info (regenerated by predev/prebuild/postversion)
/src/generated/
# debug
npm-debug.log*
yarn-debug.log*

View file

@ -5,12 +5,13 @@
"license": "MIT",
"type": "module",
"scripts": {
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build --webpack",
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev --webpack",
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev --webpack",
"devturbo": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev --turbo",
"build": "bun run generate:version-info && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build --webpack",
"dev": "bun run generate:version-info && cross-env NODE_OPTIONS=--no-deprecation next dev --webpack",
"devsafe": "rm -rf .next && bun run generate:version-info && cross-env NODE_OPTIONS=--no-deprecation next dev --webpack",
"devturbo": "rm -rf .next && bun run generate:version-info && cross-env NODE_OPTIONS=--no-deprecation next dev --turbo",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"generate:version-info": "bun run src/scripts/generateVersionInfo.ts",
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"db": "cross-env NODE_OPTIONS=--no-deprecation pnpm drizzle-kit",
@ -21,7 +22,7 @@
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts",
"deploy": "bun pm version patch --message \"chore: update to %s\"",
"preversion": "bun run build",
"postversion": "bash ./build/deploy.sh"
"postversion": "bun run generate:version-info && bash ./build/deploy.sh"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View file

@ -0,0 +1,41 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import type { VersionInfo } from "@/lib/versionInfo";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const VERSION_INFO_PATH = path.join(
process.cwd(),
"src",
"generated",
"versionInfo.json",
);
const EMPTY: VersionInfo = {
version: null,
tag: null,
gitDescribe: null,
commitHash: null,
commitDate: null,
commitSubject: null,
buildTime: null,
};
const JSON_HEADERS = {
"Content-Type": "application/json",
"Cache-Control": "no-store",
} as const;
export async function GET(): Promise<Response> {
try {
if (!existsSync(VERSION_INFO_PATH)) {
return new Response(JSON.stringify(EMPTY), { headers: JSON_HEADERS, status: 200 });
}
const raw = readFileSync(VERSION_INFO_PATH, "utf8");
const data = JSON.parse(raw) as VersionInfo;
return new Response(JSON.stringify(data), { headers: JSON_HEADERS, status: 200 });
} catch {
return new Response(JSON.stringify(EMPTY), { headers: JSON_HEADERS, status: 200 });
}
}

View file

@ -1,16 +1,73 @@
"use client";
import { useEffect, useState } from "react";
import type { VersionInfo } from "@/lib/versionInfo";
const EMPTY: VersionInfo = {
version: null,
tag: null,
gitDescribe: null,
commitHash: null,
commitDate: null,
commitSubject: null,
buildTime: null,
};
function resolveVersionLabel(info: VersionInfo): string {
return info.gitDescribe ?? info.tag ?? info.version ?? "unknown version";
}
function formatCommitDate(iso: string | null): string | null {
if (!iso) return null;
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
const VersionOverlay = () => {
const appVersion = process.env.version;
const appLastUpdated = process.env.LAST_UPDATED;
const [info, setInfo] = useState<VersionInfo | null>(null);
useEffect(() => {
let cancelled = false;
void fetch("/api/version", { cache: "no-store" })
.then((res) => res.json() as Promise<VersionInfo>)
.then((data) => {
if (!cancelled) setInfo(data);
})
.catch(() => {
if (!cancelled) setInfo(EMPTY);
});
return () => {
cancelled = true;
};
}, []);
const resolved = info ?? EMPTY;
const versionLabel = resolveVersionLabel(resolved);
const lastUpdated = formatCommitDate(resolved.commitDate);
const commitInfo =
resolved.commitHash && resolved.commitSubject
? `${resolved.commitHash} · ${resolved.commitSubject}`
: (resolved.commitHash ?? resolved.commitSubject ?? null);
const showSecondary = lastUpdated !== null || commitInfo !== null;
return (
<div className="fixed top-0 z-9999 w-full flex justify-center">
<div className="text-white flex flex-col items-center justify-between bg-red-950 w-fit px-2 rounded-b group overflow-hidden">
<div className="text-white flex flex-col items-center justify-center bg-red-950 w-fit px-2 rounded-b group overflow-hidden">
<span className="text-center text-sm pointer-events-none">
{appVersion ? `version ${appVersion}` : "unknown version"}
version {versionLabel}
</span>
{appLastUpdated && (
{showSecondary && (
<span className="text-center text-xs h-0 group-hover:h-full pointer-events-none transition-all duration-100 ease-in-out">
last updated {new Date(Number(appLastUpdated) * 1000).toLocaleString()}
{lastUpdated && <>last updated {lastUpdated}</>}
{lastUpdated && commitInfo && <br />}
{commitInfo && <>{commitInfo}</>}
</span>
)}
</div>

9
src/lib/versionInfo.ts Normal file
View file

@ -0,0 +1,9 @@
export interface VersionInfo {
version: string | null;
tag: string | null;
gitDescribe: string | null;
commitHash: string | null;
commitDate: string | null;
commitSubject: string | null;
buildTime: string | null;
}

View file

@ -0,0 +1,70 @@
/**
* Generates src/generated/versionInfo.json with the project's current version,
* git tag/describe info, and the last commit's metadata. Consumed by the admin
* VersionOverlay via GET /api/version (the file is read at runtime so a fresh
* post-bump regeneration rsync'd to the server shows up without a rebuild).
*
* Gracefully no-ops on missing git metadata (no .git, no tags) so the JSON
* always exists with whatever fields are resolvable.
*/
import { execSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(here, "..", "..");
function git(args: string): string | null {
try {
const out = execSync(`git ${args}`, { cwd: projectRoot, encoding: "utf8" });
const trimmed = out.trim();
return trimmed.length > 0 ? trimmed : null;
} catch {
return null;
}
}
function readPackageVersion(): string | null {
try {
const pkg = JSON.parse(readFileSync(path.join(projectRoot, "package.json"), "utf8"));
return typeof pkg?.version === "string" && pkg.version.length > 0 ? pkg.version : null;
} catch {
return null;
}
}
function main(): void {
const version = readPackageVersion();
const tag = git("describe --tags --always --abbrev=0");
const gitDescribe = git("describe --tags --always --abbrev=7");
const commitHash = git("rev-parse --short HEAD");
const commitDate = git("log -1 --format=%cI");
const commitSubject = git("log -1 --format=%s");
const buildTime = new Date().toISOString();
const info = {
version,
tag,
gitDescribe,
commitHash,
commitDate,
commitSubject,
buildTime,
};
const outDir = path.join(projectRoot, "src", "generated");
if (!existsSync(outDir)) {
mkdirSync(outDir, { recursive: true });
}
writeFileSync(
path.join(outDir, "versionInfo.json"),
`${JSON.stringify(info, null, 2)}\n`,
"utf8",
);
const label = gitDescribe ?? tag ?? version ?? "unknown";
console.log(`[version-info] wrote src/generated/versionInfo.json (${label})`);
}
main();