feat(version): expose build/commit metadata via /api/version and overlay
This commit is contained in:
parent
3b0d90ea62
commit
bbfbd5966c
6 changed files with 192 additions and 11 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -23,6 +23,9 @@
|
|||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# generated version info (regenerated by predev/prebuild/postversion)
|
||||
/src/generated/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -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",
|
||||
|
|
|
|||
41
src/app/api/version/route.ts
Normal file
41
src/app/api/version/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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
9
src/lib/versionInfo.ts
Normal 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;
|
||||
}
|
||||
70
src/scripts/generateVersionInfo.ts
Normal file
70
src/scripts/generateVersionInfo.ts
Normal 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();
|
||||
Loading…
Reference in a new issue