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
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
# generated version info (regenerated by predev/prebuild/postversion)
|
||||||
|
/src/generated/
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
|
|
||||||
11
package.json
11
package.json
|
|
@ -5,12 +5,13 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build --webpack",
|
"build": "bun run generate:version-info && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build --webpack",
|
||||||
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev --webpack",
|
"dev": "bun run generate:version-info && cross-env NODE_OPTIONS=--no-deprecation next dev --webpack",
|
||||||
"devsafe": "rm -rf .next && 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 && cross-env NODE_OPTIONS=--no-deprecation next dev --turbo",
|
"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:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
|
||||||
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
|
"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",
|
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
|
||||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
||||||
"db": "cross-env NODE_OPTIONS=--no-deprecation pnpm drizzle-kit",
|
"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",
|
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts",
|
||||||
"deploy": "bun pm version patch --message \"chore: update to %s\"",
|
"deploy": "bun pm version patch --message \"chore: update to %s\"",
|
||||||
"preversion": "bun run build",
|
"preversion": "bun run build",
|
||||||
"postversion": "bash ./build/deploy.sh"
|
"postversion": "bun run generate:version-info && bash ./build/deploy.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@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 VersionOverlay = () => {
|
||||||
const appVersion = process.env.version;
|
const [info, setInfo] = useState<VersionInfo | null>(null);
|
||||||
const appLastUpdated = process.env.LAST_UPDATED;
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="fixed top-0 z-9999 w-full flex justify-center">
|
<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">
|
<span className="text-center text-sm pointer-events-none">
|
||||||
{appVersion ? `version ${appVersion}` : "unknown version"}
|
version {versionLabel}
|
||||||
</span>
|
</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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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