From 81be49f977c93807888f0769ce8f0301e2d5ede9 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Fri, 7 Aug 2026 00:34:31 -0700 Subject: [PATCH 1/3] add local environment badge --- .../layout/header/local-environment-badge.tsx | 207 ++++++++++++++++++ components/layout/header/page-header.tsx | 10 +- lib/local-build-metadata.build.ts | 29 +++ lib/local-build-metadata.ts | 65 ++++++ next.config.ts | 30 ++- package.json | 2 +- .../local-environment-badge.test.tsx | 60 +++++ tests/lib/local-build-metadata.test.ts | 77 +++++++ tests/next-config.test.ts | 30 +++ 9 files changed, 499 insertions(+), 11 deletions(-) create mode 100644 components/layout/header/local-environment-badge.tsx create mode 100644 lib/local-build-metadata.build.ts create mode 100644 lib/local-build-metadata.ts create mode 100644 tests/components/local-environment-badge.test.tsx create mode 100644 tests/lib/local-build-metadata.test.ts create mode 100644 tests/next-config.test.ts diff --git a/components/layout/header/local-environment-badge.tsx b/components/layout/header/local-environment-badge.tsx new file mode 100644 index 0000000..6a3a569 --- /dev/null +++ b/components/layout/header/local-environment-badge.tsx @@ -0,0 +1,207 @@ +"use client"; + +import * as React from "react"; +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + readLocalBuildMetadata, + type LocalBuildMetadata, +} from "@/lib/local-build-metadata"; + +const HOVER_OPEN_DELAY_MS = 200; +const HOVER_CLOSE_DELAY_MS = 150; + +export function LocalEnvironmentBadge() { + if (process.env.NODE_ENV !== "development") { + return null; + } + + return ; +} + +function DevelopmentBadge({ metadata }: { metadata: LocalBuildMetadata }) { + const [open, setOpen] = React.useState(false); + const openSource = React.useRef<"explicit" | "hover" | null>(null); + const openTimer = React.useRef(null); + const closeTimer = React.useRef(null); + const closingFromHover = React.useRef(false); + + React.useEffect( + () => () => { + if (openTimer.current !== null) { + window.clearTimeout(openTimer.current); + } + if (closeTimer.current !== null) { + window.clearTimeout(closeTimer.current); + } + }, + [] + ); + + function clearTimers() { + if (openTimer.current !== null) { + window.clearTimeout(openTimer.current); + openTimer.current = null; + } + + if (closeTimer.current !== null) { + window.clearTimeout(closeTimer.current); + closeTimer.current = null; + } + } + + function openOnHover(event: React.PointerEvent) { + if (event.pointerType === "touch" || openSource.current === "explicit") { + return; + } + + clearTimers(); + openTimer.current = window.setTimeout(() => { + openSource.current = "hover"; + setOpen(true); + }, HOVER_OPEN_DELAY_MS); + } + + function closeAfterHover() { + clearTimers(); + + if (openSource.current !== "hover") { + return; + } + + closeTimer.current = window.setTimeout(() => { + closingFromHover.current = true; + openSource.current = null; + setOpen(false); + }, HOVER_CLOSE_DELAY_MS); + } + + function changeOpen(nextOpen: boolean) { + clearTimers(); + + if (nextOpen) { + openSource.current ??= "explicit"; + } else { + closingFromHover.current = openSource.current === "hover"; + openSource.current = null; + } + + setOpen(nextOpen); + } + + function pinHoveredPopover(event: React.MouseEvent) { + if (open && openSource.current === "hover") { + event.preventDefault(); + clearTimers(); + openSource.current = "explicit"; + } + } + + return ( + + + + LOCAL + + + { + if (openSource.current === "hover") { + event.preventDefault(); + } + }} + onCloseAutoFocus={(event) => { + if (closingFromHover.current) { + event.preventDefault(); + closingFromHover.current = false; + } + }} + > + + + + ); +} + +export function LocalBuildDetails({ metadata }: { metadata: LocalBuildMetadata }) { + const builtAt = new Date(metadata.builtAt); + const builtAtLabel = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(builtAt); + const relativeBuiltAt = formatRelativeTime(builtAt, new Date()); + + return ( +
+ + {metadata.branch} + + + {metadata.commitSha.slice(0, 8)} + {metadata.dirty ? (dirty) : null} + + + {metadata.commitSubject} + + + + +
+ ); +} + +function BuildDetail({ + children, + label, + title, +}: { + children: React.ReactNode; + label: string; + title: string; +}) { + return ( + <> +
{label}
+
+ {children} +
+ + ); +} + +function formatRelativeTime(date: Date, now: Date) { + const seconds = Math.round((date.getTime() - now.getTime()) / 1000); + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + const absoluteSeconds = Math.abs(seconds); + + if (absoluteSeconds < 60) { + return formatter.format(seconds, "second"); + } + if (absoluteSeconds < 60 * 60) { + return formatter.format(Math.round(seconds / 60), "minute"); + } + if (absoluteSeconds < 60 * 60 * 24) { + return formatter.format(Math.round(seconds / (60 * 60)), "hour"); + } + + return formatter.format(Math.round(seconds / (60 * 60 * 24)), "day"); +} diff --git a/components/layout/header/page-header.tsx b/components/layout/header/page-header.tsx index eefc7aa..6bd60e7 100644 --- a/components/layout/header/page-header.tsx +++ b/components/layout/header/page-header.tsx @@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { CategoryIcon } from "@/components/common/icons/category-icon"; import { Skeleton } from "@/components/ui/skeleton"; +import { LocalEnvironmentBadge } from "@/components/layout/header/local-environment-badge"; interface PageHeaderProps { title: string; @@ -78,14 +79,19 @@ export function PageHeader({ title, description, children, appLogo }: PageHeader
-

{title}

+
+

{title}

+ +
{description && (

{description}

)}
{children && ( -
{children}
+
+ {children} +
)} diff --git a/lib/local-build-metadata.build.ts b/lib/local-build-metadata.build.ts new file mode 100644 index 0000000..978ab42 --- /dev/null +++ b/lib/local-build-metadata.build.ts @@ -0,0 +1,29 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { parseLocalBuildMetadata } from "./local-build-metadata"; + +const repositoryRoot = fileURLToPath(new URL("..", import.meta.url)); + +export function discoverLocalBuildMetadata() { + const branch = runGit(["branch", "--show-current"]); + + if (!branch) { + throw new Error("Local development requires an attached Git branch"); + } + + return parseLocalBuildMetadata({ + branch, + builtAt: new Date().toISOString(), + commitSha: runGit(["rev-parse", "HEAD"]), + commitSubject: runGit(["log", "-1", "--format=%s", "HEAD"]), + dirty: String(runGit(["status", "--porcelain=v1"]).length > 0), + }); +} + +function runGit(args: string[]) { + return execFileSync("git", args, { + cwd: repositoryRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }).trim(); +} diff --git a/lib/local-build-metadata.ts b/lib/local-build-metadata.ts new file mode 100644 index 0000000..d4473b0 --- /dev/null +++ b/lib/local-build-metadata.ts @@ -0,0 +1,65 @@ +export interface LocalBuildMetadata { + branch: string; + builtAt: string; + commitSha: string; + commitSubject: string; + dirty: boolean; +} + +interface LocalBuildMetadataInput { + branch: string | undefined; + builtAt: string | undefined; + commitSha: string | undefined; + commitSubject: string | undefined; + dirty: string | undefined; +} + +const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,64}$/i; +const ISO_8601_TIMESTAMP_PATTERN = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + +export function readLocalBuildMetadata(): LocalBuildMetadata { + return parseLocalBuildMetadata({ + branch: process.env.APP_BUILD_BRANCH, + builtAt: process.env.APP_BUILD_AT, + commitSha: process.env.APP_BUILD_COMMIT_SHA, + commitSubject: process.env.APP_BUILD_COMMIT_SUBJECT, + dirty: process.env.APP_BUILD_DIRTY, + }); +} + +export function parseLocalBuildMetadata(input: LocalBuildMetadataInput): LocalBuildMetadata { + const branch = requireValue("APP_BUILD_BRANCH", input.branch); + const builtAt = requireValue("APP_BUILD_AT", input.builtAt); + const commitSha = requireValue("APP_BUILD_COMMIT_SHA", input.commitSha); + const commitSubject = requireValue("APP_BUILD_COMMIT_SUBJECT", input.commitSubject); + const dirty = requireValue("APP_BUILD_DIRTY", input.dirty); + + if (!COMMIT_SHA_PATTERN.test(commitSha)) { + throw new Error("APP_BUILD_COMMIT_SHA must be a Git commit SHA"); + } + + if (!ISO_8601_TIMESTAMP_PATTERN.test(builtAt) || Number.isNaN(Date.parse(builtAt))) { + throw new Error("APP_BUILD_AT must be an ISO 8601 timestamp"); + } + + if (dirty !== "false" && dirty !== "true") { + throw new Error('APP_BUILD_DIRTY must be either "true" or "false"'); + } + + return { + branch, + builtAt, + commitSha, + commitSubject, + dirty: dirty === "true", + }; +} + +function requireValue(name: string, value: string | undefined) { + if (!value) { + throw new Error(`${name} is required`); + } + + return value; +} diff --git a/next.config.ts b/next.config.ts index ba1e7c7..c236a6b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,12 +1,26 @@ import type { NextConfig } from "next"; +import { PHASE_DEVELOPMENT_SERVER, type PHASE_TYPE } from "next/constants"; +import { discoverLocalBuildMetadata } from "./lib/local-build-metadata.build"; import packageJson from "./package.json"; -const nextConfig: NextConfig = { - // Produce standalone output for self-hosted deploys (Docker or zipped bundle) - output: "standalone", - env: { - NEXT_PUBLIC_APP_VERSION: packageJson.version, - }, -}; +export default function createNextConfig(phase: PHASE_TYPE): NextConfig { + const localBuildMetadata = + phase === PHASE_DEVELOPMENT_SERVER ? discoverLocalBuildMetadata() : null; -export default nextConfig; + return { + // Produce standalone output for self-hosted deploys (Docker or zipped bundle) + output: "standalone", + env: { + NEXT_PUBLIC_APP_VERSION: packageJson.version, + ...(localBuildMetadata + ? { + APP_BUILD_AT: localBuildMetadata.builtAt, + APP_BUILD_BRANCH: localBuildMetadata.branch, + APP_BUILD_COMMIT_SHA: localBuildMetadata.commitSha, + APP_BUILD_COMMIT_SUBJECT: localBuildMetadata.commitSubject, + APP_BUILD_DIRTY: String(localBuildMetadata.dirty), + } + : {}), + }, + }; +} diff --git a/package.json b/package.json index e0b7fb4..1cc982c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crapdash", - "version": "1.4.1", + "version": "1.4.2", "private": true, "license": "MIT", "packageManager": "pnpm@11.0.3", diff --git a/tests/components/local-environment-badge.test.tsx b/tests/components/local-environment-badge.test.tsx new file mode 100644 index 0000000..42efb21 --- /dev/null +++ b/tests/components/local-environment-badge.test.tsx @@ -0,0 +1,60 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + LocalBuildDetails, + LocalEnvironmentBadge, +} from "@/components/layout/header/local-environment-badge"; + +const buildMetadata = { + branch: "environment-badge", + builtAt: "2026-08-07T07:15:00.000Z", + commitSha: "98556cc1d4a18439855616c0b86e4eaa6b5d2821", + commitSubject: "Add local environment badge details", + dirty: true, +}; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("LocalEnvironmentBadge", () => { + it("renders a local badge in development", () => { + vi.stubEnv("NODE_ENV", "development"); + vi.stubEnv("APP_BUILD_BRANCH", buildMetadata.branch); + vi.stubEnv("APP_BUILD_AT", buildMetadata.builtAt); + vi.stubEnv("APP_BUILD_COMMIT_SHA", buildMetadata.commitSha); + vi.stubEnv("APP_BUILD_COMMIT_SUBJECT", buildMetadata.commitSubject); + vi.stubEnv("APP_BUILD_DIRTY", String(buildMetadata.dirty)); + + const html = renderToStaticMarkup(); + + expect(html).toContain('data-slot="badge"'); + expect(html).toContain('data-variant="outline"'); + expect(html).toContain("rounded-sm"); + expect(html).not.toContain("rounded-full"); + expect(html).toContain("LOCAL"); + expect(html).toContain("Local development environment. Show build details"); + expect(html).toContain('aria-haspopup="dialog"'); + expect(html).toContain(" { + vi.stubEnv("NODE_ENV", environment); + + expect(renderToStaticMarkup()).toBe(""); + }); + + it("renders complete local build details", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("BRANCH"); + expect(html).toContain(buildMetadata.branch); + expect(html).toContain("COMMIT"); + expect(html).toContain(buildMetadata.commitSha.slice(0, 8)); + expect(html).toContain("(dirty)"); + expect(html).toContain("MESSAGE"); + expect(html).toContain(buildMetadata.commitSubject); + expect(html).toContain("BUILT"); + expect(html).toContain(`dateTime="${buildMetadata.builtAt}"`); + }); +}); diff --git a/tests/lib/local-build-metadata.test.ts b/tests/lib/local-build-metadata.test.ts new file mode 100644 index 0000000..7203923 --- /dev/null +++ b/tests/lib/local-build-metadata.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + parseLocalBuildMetadata, + readLocalBuildMetadata, +} from "@/lib/local-build-metadata"; + +const validInput = { + branch: "environment-badge", + builtAt: "2026-08-07T07:15:00.000Z", + commitSha: "98556cc1d4a18439855616c0b86e4eaa6b5d2821", + commitSubject: "Add local environment badge details", + dirty: "true", +}; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("parseLocalBuildMetadata", () => { + it("parses valid local build metadata", () => { + expect(parseLocalBuildMetadata(validInput)).toEqual({ + branch: validInput.branch, + builtAt: validInput.builtAt, + commitSha: validInput.commitSha, + commitSubject: validInput.commitSubject, + dirty: true, + }); + }); + + it.each([ + ["branch", "APP_BUILD_BRANCH"], + ["builtAt", "APP_BUILD_AT"], + ["commitSha", "APP_BUILD_COMMIT_SHA"], + ["commitSubject", "APP_BUILD_COMMIT_SUBJECT"], + ["dirty", "APP_BUILD_DIRTY"], + ] as const)("rejects a missing %s", (field, variableName) => { + expect(() => parseLocalBuildMetadata({ ...validInput, [field]: undefined })).toThrow( + `${variableName} is required` + ); + }); + + it("rejects an invalid commit SHA", () => { + expect(() => + parseLocalBuildMetadata({ ...validInput, commitSha: "not-a-commit" }) + ).toThrow("APP_BUILD_COMMIT_SHA must be a Git commit SHA"); + }); + + it("rejects an invalid build timestamp", () => { + expect(() => parseLocalBuildMetadata({ ...validInput, builtAt: "yesterday" })).toThrow( + "APP_BUILD_AT must be an ISO 8601 timestamp" + ); + }); + + it("rejects an invalid dirty flag", () => { + expect(() => parseLocalBuildMetadata({ ...validInput, dirty: "yes" })).toThrow( + 'APP_BUILD_DIRTY must be either "true" or "false"' + ); + }); +}); + +describe("readLocalBuildMetadata", () => { + it("reads embedded build metadata", () => { + vi.stubEnv("APP_BUILD_BRANCH", validInput.branch); + vi.stubEnv("APP_BUILD_AT", validInput.builtAt); + vi.stubEnv("APP_BUILD_COMMIT_SHA", validInput.commitSha); + vi.stubEnv("APP_BUILD_COMMIT_SUBJECT", validInput.commitSubject); + vi.stubEnv("APP_BUILD_DIRTY", validInput.dirty); + + expect(readLocalBuildMetadata()).toEqual({ + branch: validInput.branch, + builtAt: validInput.builtAt, + commitSha: validInput.commitSha, + commitSubject: validInput.commitSubject, + dirty: true, + }); + }); +}); diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts new file mode 100644 index 0000000..d30a79d --- /dev/null +++ b/tests/next-config.test.ts @@ -0,0 +1,30 @@ +import { + PHASE_DEVELOPMENT_SERVER, + PHASE_PRODUCTION_BUILD, +} from "next/constants"; +import { describe, expect, it } from "vitest"; +import createNextConfig from "@/next.config"; +import packageJson from "@/package.json"; + +describe("next config local build metadata", () => { + it("embeds Git metadata for the development server", () => { + const config = createNextConfig(PHASE_DEVELOPMENT_SERVER); + + expect(config.env).toMatchObject({ + NEXT_PUBLIC_APP_VERSION: packageJson.version, + APP_BUILD_BRANCH: expect.any(String), + APP_BUILD_AT: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + APP_BUILD_COMMIT_SHA: expect.stringMatching(/^[0-9a-f]{40}$/), + APP_BUILD_COMMIT_SUBJECT: expect.any(String), + APP_BUILD_DIRTY: expect.stringMatching(/^(?:true|false)$/), + }); + }); + + it("excludes local Git metadata from production builds", () => { + const config = createNextConfig(PHASE_PRODUCTION_BUILD); + + expect(config.env).toEqual({ + NEXT_PUBLIC_APP_VERSION: packageJson.version, + }); + }); +}); From 3a449adad141d462dc81d23632dfbb2312c0b377 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Fri, 7 Aug 2026 00:47:42 -0700 Subject: [PATCH 2/3] fix environment badge naming and ci --- ...onment-badge.tsx => environment-badge.tsx} | 20 ++++++------- components/layout/header/page-header.tsx | 4 +-- ...adata.build.ts => build-metadata.build.ts} | 8 ++--- ...al-build-metadata.ts => build-metadata.ts} | 10 +++---- next.config.ts | 18 +++++------ ...ge.test.tsx => environment-badge.test.tsx} | 22 +++++++------- ...etadata.test.ts => build-metadata.test.ts} | 26 ++++++++-------- tests/next-config.test.ts | 30 +++++++++++++------ 8 files changed, 75 insertions(+), 63 deletions(-) rename components/layout/header/{local-environment-badge.tsx => environment-badge.tsx} (90%) rename lib/{local-build-metadata.build.ts => build-metadata.build.ts} (74%) rename lib/{local-build-metadata.ts => build-metadata.ts} (86%) rename tests/components/{local-environment-badge.test.tsx => environment-badge.test.tsx} (72%) rename tests/lib/{local-build-metadata.test.ts => build-metadata.test.ts} (69%) diff --git a/components/layout/header/local-environment-badge.tsx b/components/layout/header/environment-badge.tsx similarity index 90% rename from components/layout/header/local-environment-badge.tsx rename to components/layout/header/environment-badge.tsx index 6a3a569..df889bf 100644 --- a/components/layout/header/local-environment-badge.tsx +++ b/components/layout/header/environment-badge.tsx @@ -8,22 +8,22 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { - readLocalBuildMetadata, - type LocalBuildMetadata, -} from "@/lib/local-build-metadata"; + readBuildMetadata, + type BuildMetadata, +} from "@/lib/build-metadata"; const HOVER_OPEN_DELAY_MS = 200; const HOVER_CLOSE_DELAY_MS = 150; -export function LocalEnvironmentBadge() { +export function EnvironmentBadge() { if (process.env.NODE_ENV !== "development") { return null; } - return ; + return ; } -function DevelopmentBadge({ metadata }: { metadata: LocalBuildMetadata }) { +function DevelopmentBadge({ metadata }: { metadata: BuildMetadata }) { const [open, setOpen] = React.useState(false); const openSource = React.useRef<"explicit" | "hover" | null>(null); const openTimer = React.useRef(null); @@ -106,12 +106,12 @@ function DevelopmentBadge({ metadata }: { metadata: LocalBuildMetadata }) { - LOCAL + DEV - + ); } -export function LocalBuildDetails({ metadata }: { metadata: LocalBuildMetadata }) { +export function BuildDetails({ metadata }: { metadata: BuildMetadata }) { const builtAt = new Date(metadata.builtAt); const builtAtLabel = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", diff --git a/components/layout/header/page-header.tsx b/components/layout/header/page-header.tsx index 6bd60e7..2cb967f 100644 --- a/components/layout/header/page-header.tsx +++ b/components/layout/header/page-header.tsx @@ -9,7 +9,7 @@ import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { CategoryIcon } from "@/components/common/icons/category-icon"; import { Skeleton } from "@/components/ui/skeleton"; -import { LocalEnvironmentBadge } from "@/components/layout/header/local-environment-badge"; +import { EnvironmentBadge } from "@/components/layout/header/environment-badge"; interface PageHeaderProps { title: string; @@ -81,7 +81,7 @@ export function PageHeader({ title, description, children, appLogo }: PageHeader

{title}

- +
{description && (

{description}

diff --git a/lib/local-build-metadata.build.ts b/lib/build-metadata.build.ts similarity index 74% rename from lib/local-build-metadata.build.ts rename to lib/build-metadata.build.ts index 978ab42..a3af55f 100644 --- a/lib/local-build-metadata.build.ts +++ b/lib/build-metadata.build.ts @@ -1,17 +1,17 @@ import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { parseLocalBuildMetadata } from "./local-build-metadata"; +import { parseBuildMetadata } from "./build-metadata"; const repositoryRoot = fileURLToPath(new URL("..", import.meta.url)); -export function discoverLocalBuildMetadata() { +export function discoverBuildMetadata() { const branch = runGit(["branch", "--show-current"]); if (!branch) { - throw new Error("Local development requires an attached Git branch"); + throw new Error("The development server requires an attached Git branch"); } - return parseLocalBuildMetadata({ + return parseBuildMetadata({ branch, builtAt: new Date().toISOString(), commitSha: runGit(["rev-parse", "HEAD"]), diff --git a/lib/local-build-metadata.ts b/lib/build-metadata.ts similarity index 86% rename from lib/local-build-metadata.ts rename to lib/build-metadata.ts index d4473b0..ec5d64d 100644 --- a/lib/local-build-metadata.ts +++ b/lib/build-metadata.ts @@ -1,4 +1,4 @@ -export interface LocalBuildMetadata { +export interface BuildMetadata { branch: string; builtAt: string; commitSha: string; @@ -6,7 +6,7 @@ export interface LocalBuildMetadata { dirty: boolean; } -interface LocalBuildMetadataInput { +interface BuildMetadataInput { branch: string | undefined; builtAt: string | undefined; commitSha: string | undefined; @@ -18,8 +18,8 @@ const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,64}$/i; const ISO_8601_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; -export function readLocalBuildMetadata(): LocalBuildMetadata { - return parseLocalBuildMetadata({ +export function readBuildMetadata(): BuildMetadata { + return parseBuildMetadata({ branch: process.env.APP_BUILD_BRANCH, builtAt: process.env.APP_BUILD_AT, commitSha: process.env.APP_BUILD_COMMIT_SHA, @@ -28,7 +28,7 @@ export function readLocalBuildMetadata(): LocalBuildMetadata { }); } -export function parseLocalBuildMetadata(input: LocalBuildMetadataInput): LocalBuildMetadata { +export function parseBuildMetadata(input: BuildMetadataInput): BuildMetadata { const branch = requireValue("APP_BUILD_BRANCH", input.branch); const builtAt = requireValue("APP_BUILD_AT", input.builtAt); const commitSha = requireValue("APP_BUILD_COMMIT_SHA", input.commitSha); diff --git a/next.config.ts b/next.config.ts index c236a6b..71c5a19 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,24 +1,24 @@ import type { NextConfig } from "next"; import { PHASE_DEVELOPMENT_SERVER, type PHASE_TYPE } from "next/constants"; -import { discoverLocalBuildMetadata } from "./lib/local-build-metadata.build"; +import { discoverBuildMetadata } from "./lib/build-metadata.build"; import packageJson from "./package.json"; export default function createNextConfig(phase: PHASE_TYPE): NextConfig { - const localBuildMetadata = - phase === PHASE_DEVELOPMENT_SERVER ? discoverLocalBuildMetadata() : null; + const buildMetadata = + phase === PHASE_DEVELOPMENT_SERVER ? discoverBuildMetadata() : null; return { // Produce standalone output for self-hosted deploys (Docker or zipped bundle) output: "standalone", env: { NEXT_PUBLIC_APP_VERSION: packageJson.version, - ...(localBuildMetadata + ...(buildMetadata ? { - APP_BUILD_AT: localBuildMetadata.builtAt, - APP_BUILD_BRANCH: localBuildMetadata.branch, - APP_BUILD_COMMIT_SHA: localBuildMetadata.commitSha, - APP_BUILD_COMMIT_SUBJECT: localBuildMetadata.commitSubject, - APP_BUILD_DIRTY: String(localBuildMetadata.dirty), + APP_BUILD_AT: buildMetadata.builtAt, + APP_BUILD_BRANCH: buildMetadata.branch, + APP_BUILD_COMMIT_SHA: buildMetadata.commitSha, + APP_BUILD_COMMIT_SUBJECT: buildMetadata.commitSubject, + APP_BUILD_DIRTY: String(buildMetadata.dirty), } : {}), }, diff --git a/tests/components/local-environment-badge.test.tsx b/tests/components/environment-badge.test.tsx similarity index 72% rename from tests/components/local-environment-badge.test.tsx rename to tests/components/environment-badge.test.tsx index 42efb21..3a4c368 100644 --- a/tests/components/local-environment-badge.test.tsx +++ b/tests/components/environment-badge.test.tsx @@ -1,15 +1,15 @@ import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, describe, expect, it, vi } from "vitest"; import { - LocalBuildDetails, - LocalEnvironmentBadge, -} from "@/components/layout/header/local-environment-badge"; + BuildDetails, + EnvironmentBadge, +} from "@/components/layout/header/environment-badge"; const buildMetadata = { branch: "environment-badge", builtAt: "2026-08-07T07:15:00.000Z", commitSha: "98556cc1d4a18439855616c0b86e4eaa6b5d2821", - commitSubject: "Add local environment badge details", + commitSubject: "Add environment badge details", dirty: true, }; @@ -17,7 +17,7 @@ afterEach(() => { vi.unstubAllEnvs(); }); -describe("LocalEnvironmentBadge", () => { +describe("EnvironmentBadge", () => { it("renders a local badge in development", () => { vi.stubEnv("NODE_ENV", "development"); vi.stubEnv("APP_BUILD_BRANCH", buildMetadata.branch); @@ -26,14 +26,14 @@ describe("LocalEnvironmentBadge", () => { vi.stubEnv("APP_BUILD_COMMIT_SUBJECT", buildMetadata.commitSubject); vi.stubEnv("APP_BUILD_DIRTY", String(buildMetadata.dirty)); - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup(); expect(html).toContain('data-slot="badge"'); expect(html).toContain('data-variant="outline"'); expect(html).toContain("rounded-sm"); expect(html).not.toContain("rounded-full"); - expect(html).toContain("LOCAL"); - expect(html).toContain("Local development environment. Show build details"); + expect(html).toContain("DEV"); + expect(html).toContain("Development environment. Show build details"); expect(html).toContain('aria-haspopup="dialog"'); expect(html).toContain(" { it.each(["production", "test"])("renders nothing in %s", (environment) => { vi.stubEnv("NODE_ENV", environment); - expect(renderToStaticMarkup()).toBe(""); + expect(renderToStaticMarkup()).toBe(""); }); - it("renders complete local build details", () => { - const html = renderToStaticMarkup(); + it("renders complete build details", () => { + const html = renderToStaticMarkup(); expect(html).toContain("BRANCH"); expect(html).toContain(buildMetadata.branch); diff --git a/tests/lib/local-build-metadata.test.ts b/tests/lib/build-metadata.test.ts similarity index 69% rename from tests/lib/local-build-metadata.test.ts rename to tests/lib/build-metadata.test.ts index 7203923..56b7b61 100644 --- a/tests/lib/local-build-metadata.test.ts +++ b/tests/lib/build-metadata.test.ts @@ -1,14 +1,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { - parseLocalBuildMetadata, - readLocalBuildMetadata, -} from "@/lib/local-build-metadata"; + parseBuildMetadata, + readBuildMetadata, +} from "@/lib/build-metadata"; const validInput = { branch: "environment-badge", builtAt: "2026-08-07T07:15:00.000Z", commitSha: "98556cc1d4a18439855616c0b86e4eaa6b5d2821", - commitSubject: "Add local environment badge details", + commitSubject: "Add environment badge details", dirty: "true", }; @@ -16,9 +16,9 @@ afterEach(() => { vi.unstubAllEnvs(); }); -describe("parseLocalBuildMetadata", () => { - it("parses valid local build metadata", () => { - expect(parseLocalBuildMetadata(validInput)).toEqual({ +describe("parseBuildMetadata", () => { + it("parses valid build metadata", () => { + expect(parseBuildMetadata(validInput)).toEqual({ branch: validInput.branch, builtAt: validInput.builtAt, commitSha: validInput.commitSha, @@ -34,31 +34,31 @@ describe("parseLocalBuildMetadata", () => { ["commitSubject", "APP_BUILD_COMMIT_SUBJECT"], ["dirty", "APP_BUILD_DIRTY"], ] as const)("rejects a missing %s", (field, variableName) => { - expect(() => parseLocalBuildMetadata({ ...validInput, [field]: undefined })).toThrow( + expect(() => parseBuildMetadata({ ...validInput, [field]: undefined })).toThrow( `${variableName} is required` ); }); it("rejects an invalid commit SHA", () => { expect(() => - parseLocalBuildMetadata({ ...validInput, commitSha: "not-a-commit" }) + parseBuildMetadata({ ...validInput, commitSha: "not-a-commit" }) ).toThrow("APP_BUILD_COMMIT_SHA must be a Git commit SHA"); }); it("rejects an invalid build timestamp", () => { - expect(() => parseLocalBuildMetadata({ ...validInput, builtAt: "yesterday" })).toThrow( + expect(() => parseBuildMetadata({ ...validInput, builtAt: "yesterday" })).toThrow( "APP_BUILD_AT must be an ISO 8601 timestamp" ); }); it("rejects an invalid dirty flag", () => { - expect(() => parseLocalBuildMetadata({ ...validInput, dirty: "yes" })).toThrow( + expect(() => parseBuildMetadata({ ...validInput, dirty: "yes" })).toThrow( 'APP_BUILD_DIRTY must be either "true" or "false"' ); }); }); -describe("readLocalBuildMetadata", () => { +describe("readBuildMetadata", () => { it("reads embedded build metadata", () => { vi.stubEnv("APP_BUILD_BRANCH", validInput.branch); vi.stubEnv("APP_BUILD_AT", validInput.builtAt); @@ -66,7 +66,7 @@ describe("readLocalBuildMetadata", () => { vi.stubEnv("APP_BUILD_COMMIT_SUBJECT", validInput.commitSubject); vi.stubEnv("APP_BUILD_DIRTY", validInput.dirty); - expect(readLocalBuildMetadata()).toEqual({ + expect(readBuildMetadata()).toEqual({ branch: validInput.branch, builtAt: validInput.builtAt, commitSha: validInput.commitSha, diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index d30a79d..e4bd7ef 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -1,26 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; import { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_BUILD, } from "next/constants"; -import { describe, expect, it } from "vitest"; import createNextConfig from "@/next.config"; import packageJson from "@/package.json"; -describe("next config local build metadata", () => { +const buildMetadata = vi.hoisted(() => ({ + branch: "environment-badge", + builtAt: "2026-08-07T07:15:00.000Z", + commitSha: "98556cc1d4a18439855616c0b86e4eaa6b5d2821", + commitSubject: "Add environment badge details", + dirty: true, +})); + +vi.mock("@/lib/build-metadata.build", () => ({ + discoverBuildMetadata: vi.fn(() => buildMetadata), +})); + +describe("next config build metadata", () => { it("embeds Git metadata for the development server", () => { const config = createNextConfig(PHASE_DEVELOPMENT_SERVER); - expect(config.env).toMatchObject({ + expect(config.env).toEqual({ NEXT_PUBLIC_APP_VERSION: packageJson.version, - APP_BUILD_BRANCH: expect.any(String), - APP_BUILD_AT: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), - APP_BUILD_COMMIT_SHA: expect.stringMatching(/^[0-9a-f]{40}$/), - APP_BUILD_COMMIT_SUBJECT: expect.any(String), - APP_BUILD_DIRTY: expect.stringMatching(/^(?:true|false)$/), + APP_BUILD_BRANCH: buildMetadata.branch, + APP_BUILD_AT: buildMetadata.builtAt, + APP_BUILD_COMMIT_SHA: buildMetadata.commitSha, + APP_BUILD_COMMIT_SUBJECT: buildMetadata.commitSubject, + APP_BUILD_DIRTY: String(buildMetadata.dirty), }); }); - it("excludes local Git metadata from production builds", () => { + it("excludes Git metadata from production builds", () => { const config = createNextConfig(PHASE_PRODUCTION_BUILD); expect(config.env).toEqual({ From 6f16ad7d6bd84cdd1df09ce7fd3fd22f5f13a264 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Fri, 7 Aug 2026 00:58:58 -0700 Subject: [PATCH 3/3] support detached development checkouts --- lib/build-metadata.build.ts | 21 +++++----- tests/lib/build-metadata.build.test.ts | 56 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 10 deletions(-) create mode 100644 tests/lib/build-metadata.build.test.ts diff --git a/lib/build-metadata.build.ts b/lib/build-metadata.build.ts index a3af55f..1096b61 100644 --- a/lib/build-metadata.build.ts +++ b/lib/build-metadata.build.ts @@ -4,25 +4,26 @@ import { parseBuildMetadata } from "./build-metadata"; const repositoryRoot = fileURLToPath(new URL("..", import.meta.url)); -export function discoverBuildMetadata() { - const branch = runGit(["branch", "--show-current"]); +interface DiscoverBuildMetadataOptions { + repositoryRoot?: string; +} - if (!branch) { - throw new Error("The development server requires an attached Git branch"); - } +export function discoverBuildMetadata(options: DiscoverBuildMetadataOptions = {}) { + const root = options.repositoryRoot ?? repositoryRoot; + const branch = runGit(["branch", "--show-current"], root) || "detached"; return parseBuildMetadata({ branch, builtAt: new Date().toISOString(), - commitSha: runGit(["rev-parse", "HEAD"]), - commitSubject: runGit(["log", "-1", "--format=%s", "HEAD"]), - dirty: String(runGit(["status", "--porcelain=v1"]).length > 0), + commitSha: runGit(["rev-parse", "HEAD"], root), + commitSubject: runGit(["log", "-1", "--format=%s", "HEAD"], root), + dirty: String(runGit(["status", "--porcelain=v1"], root).length > 0), }); } -function runGit(args: string[]) { +function runGit(args: string[], cwd: string) { return execFileSync("git", args, { - cwd: repositoryRoot, + cwd, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"], }).trim(); diff --git a/tests/lib/build-metadata.build.test.ts b/tests/lib/build-metadata.build.test.ts new file mode 100644 index 0000000..6431098 --- /dev/null +++ b/tests/lib/build-metadata.build.test.ts @@ -0,0 +1,56 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { discoverBuildMetadata } from "@/lib/build-metadata.build"; + +let repositoryRoot: string; + +beforeEach(async () => { + repositoryRoot = await mkdtemp(join(tmpdir(), "crapdash-build-metadata-")); + runGit(["init", "--initial-branch=main"]); + runGit(["config", "user.email", "tests@crapdash.invalid"]); + runGit(["config", "user.name", "Crapdash Tests"]); + runGit(["config", "commit.gpgsign", "false"]); + await writeFile(join(repositoryRoot, "dashboard.txt"), "initial\n"); + runGit(["add", "dashboard.txt"]); + runGit(["commit", "--message", "Add dashboard"]); +}); + +afterEach(async () => { + await rm(repositoryRoot, { recursive: true, force: true }); +}); + +describe("discoverBuildMetadata", () => { + it("discovers metadata from an attached branch", () => { + const metadata = discoverBuildMetadata({ repositoryRoot }); + + expect(metadata).toMatchObject({ + branch: "main", + commitSha: runGit(["rev-parse", "HEAD"]), + commitSubject: "Add dashboard", + dirty: false, + }); + expect(metadata.builtAt).toEqual(expect.any(String)); + }); + + it("labels a detached checkout without rejecting it", () => { + runGit(["switch", "--detach", "HEAD"]); + + expect(discoverBuildMetadata({ repositoryRoot })).toMatchObject({ + branch: "detached", + commitSha: runGit(["rev-parse", "HEAD"]), + commitSubject: "Add dashboard", + dirty: false, + }); + }); +}); + +function runGit(args: string[]) { + return execFileSync("git", args, { + cwd: repositoryRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); +}