diff --git a/next.config.ts b/next.config.ts index c4ce4803..c0dcac25 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,18 @@ import type { NextConfig } from "next"; import { withBotId } from "botid/next/config"; +import { execSync } from "node:child_process"; +import pkg from "./package.json" with { type: "json" }; + +// The running version (docs: learnings/2026-10-23-app-version-in-footer.md): baked in at build time, read only by src/lib/version.ts. +const git = (cmd: string) => { + try { + return execSync(cmd).toString().trim(); + } catch { + return ""; + } +}; +const sha = process.env.VERCEL_GIT_COMMIT_SHA || git("git rev-parse HEAD"); +const dirty = !process.env.VERCEL && git("git status --porcelain") !== ""; const distDir = process.env.NEXT_DIST_DIR || ".next"; @@ -10,7 +23,16 @@ const nextConfig: NextConfig = { typescript: { tsconfigPath: distDir === ".next" ? "tsconfig.json" : "tsconfig.dist.json" }, cacheComponents: true, // BotID is checked only on Vercel (VERCEL=1 during Vercel builds); see src/server/bot.ts. - env: { NEXT_PUBLIC_BUSKMAP_BOTID: process.env.VERCEL ? "1" : "" }, + env: { + NEXT_PUBLIC_BUSKMAP_BOTID: process.env.VERCEL ? "1" : "", + NEXT_PUBLIC_APP_VERSION: pkg.version, + NEXT_PUBLIC_APP_SHA: sha, + NEXT_PUBLIC_APP_ENV: process.env.VERCEL_ENV || "local", + NEXT_PUBLIC_APP_BRANCH: process.env.VERCEL_GIT_COMMIT_REF || git("git rev-parse --abbrev-ref HEAD"), + NEXT_PUBLIC_APP_DIRTY: dirty ? "1" : "", + NEXT_PUBLIC_APP_BUILT_AT: new Date().toISOString(), + NEXT_PUBLIC_APP_REPO: "https://github.com/T04435/busk-map", + }, partialPrefetching: true, // Vercel restores .next/cache between deployments. A stale Turbopack cache served an old globals.css (the create-next-app one: // no Honeycomb tokens, fonts or --taste-*) on a preview, so the build cache is off; builds recompile from the source every time. diff --git a/package.json b/package.json index d23454b2..062a095e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "busk-map", - "version": "0.1.0", + "version": "1.0.2", "private": true, "scripts": { "dev": "node scripts/copy-maplibre-worker.mjs && next dev", @@ -16,6 +16,7 @@ "test:e2e": "playwright test", "e2e:late": "BUSKMAP_FAKE_NOW=2026-10-16T21:30:00+11:00 playwright test late-clock.spec.ts", "check:fixtures": "node scripts/check-fixtures.mjs", + "check:version-tag": "node scripts/check-version-tag.mjs", "check": "pnpm typecheck && pnpm lint && pnpm test:coverage && pnpm check:fixtures && pnpm build", "db:migrate:dev": "node --env-file=.env.development.local scripts/migrate.mjs", "prepare": "simple-git-hooks || true", diff --git a/scripts/check-version-tag.mjs b/scripts/check-version-tag.mjs new file mode 100644 index 00000000..b50ccc86 --- /dev/null +++ b/scripts/check-version-tag.mjs @@ -0,0 +1,26 @@ +// Fails if the git tag is not "v" + package.json's version. Tag = the first argument, else GITHUB_REF_NAME (tag pushes in CI), +// else the tag at HEAD (local pre-push). With no tag at all there is nothing to check. +import { execSync } from "node:child_process"; +import { readFileSync } from "node:fs"; + +const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); +const fromGit = () => { + try { + return execSync("git tag --points-at HEAD", { stdio: ["ignore", "pipe", "ignore"] }).toString().split("\n").filter((t) => /^v\d/.test(t)); + } catch { + return []; + } +}; +const tags = process.argv[2] ? [process.argv[2]] : process.env.GITHUB_REF_TYPE === "tag" && process.env.GITHUB_REF_NAME ? [process.env.GITHUB_REF_NAME] : fromGit(); + +if (tags.length === 0) { + console.log("check-version-tag: no version tag to check."); + process.exit(0); +} +const expected = `v${pkg.version}`; +const bad = tags.filter((t) => t !== expected); +if (bad.length > 0) { + console.error(`check-version-tag: tag ${bad.join(", ")} does not match package.json version (expected ${expected}).`); + process.exit(1); +} +console.log(`check-version-tag: ${tags.join(", ")} matches package.json.`); diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts new file mode 100644 index 00000000..7dbd53a8 --- /dev/null +++ b/src/app/api/version/route.ts @@ -0,0 +1,5 @@ +import { appVersion } from "@/lib/version"; +// No `dynamic = "force-static"`: it is not allowed with cacheComponents. A GET with no request data is static by default. +export function GET() { + return Response.json({ ...appVersion, deploymentId: process.env.VERCEL_DEPLOYMENT_ID ?? null }); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7ecc0541..91ca8cf7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import { AppHeader, AppHeaderView } from "@/components/honeycomb/app-header"; import { HAS_BOTTOM_NAV } from "@/components/honeycomb/nav-items"; import { MobileNav, MobileNavView } from "@/components/honeycomb/mobile-nav"; import { SiteFooter, SiteFooterView } from "@/components/honeycomb/site-footer"; +import { versionText } from "@/lib/version"; // Honeycomb's three families (design-handoff/fonts.md). Google renamed "Big Shoulders Display" to "Big Shoulders" (the same // family, now with an optical-size axis), so next/font imports it under the new name; the CSS stack still names both. globals.css pins opsz to 72, the Display cut. globals.css reads the -src variables and adds the fallback stacks. @@ -28,6 +29,7 @@ export const metadata: Metadata = { description: DESCRIPTION, openGraph: { type: "website", siteName: TITLE, title: TITLE, description: DESCRIPTION, locale: "en_AU", images: [OG_IMAGE] }, twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION, images: [{ url: OG_IMAGE.url, alt: OG_IMAGE.alt }] }, + other: { "app-version": versionText() }, }; export default function RootLayout({ children }: LayoutProps<"/">) { diff --git a/src/components/honeycomb/site-footer.tsx b/src/components/honeycomb/site-footer.tsx index b300f926..465114a2 100644 --- a/src/components/honeycomb/site-footer.tsx +++ b/src/components/honeycomb/site-footer.tsx @@ -5,6 +5,7 @@ import { usePathname } from "next/navigation"; import { cn } from "cn"; import { HexCell } from "@/components/honeycomb/hex-cell"; import { Logo, QlooWordmark } from "@/components/honeycomb/logo"; +import { VersionBadge } from "@/components/honeycomb/version-badge"; const REPO = "https://github.com/T04435/busk-map"; const AUTHOR = "https://github.com/T04435"; @@ -29,7 +30,7 @@ const LINK = "inline-flex min-h-tap items-center font-bold text-foreground under /** * The footer on every page, in three parts: on the left the wordmark and the Qloo credit, in the centre Privacy, Brain and Brand, - * on the right GitHub (an icon in a cell) and "Built by T04435" with the pixel critter. At 390px the parts stack. The legal line + * on the right GitHub (an icon in a cell) and "Built by T04435" with the pixel critter, and on a line of its own at the bottom, centred, the running version (VersionBadge). At 390px the parts stack. The legal line * lives on /terms, which the footer links to. On the landing (`dark`) it continues the closing call to action's dark band, as in * the deck; on every other page it sits on paper. */ @@ -73,6 +74,9 @@ export function SiteFooterView({ repo = REPO, dark = false, night = false }: { r +
+ +
); diff --git a/src/components/honeycomb/version-badge.tsx b/src/components/honeycomb/version-badge.tsx new file mode 100644 index 00000000..e2d6d215 --- /dev/null +++ b/src/components/honeycomb/version-badge.tsx @@ -0,0 +1,26 @@ +import { Badge } from "@/components/ui/badge"; +import { appVersion, envTag, type AppVersion } from "@/lib/version"; + +/** + * The running version (learnings/2026-10-23-app-version-in-footer.md): the shared Badge in its default (primary) variant, mono, one + * colour for everything. `v1.0.2 @ 3f1addd`, then ` · preview · ` or ` · local` (+ ` · dirty`) off production. + * The tag links to its release and the hash to its commit; links inherit the colour and underline on hover. + */ +export function VersionBadge({ version = appVersion, className }: { version?: AppVersion; className?: string }) { + const env = envTag(version); + const rest = env === "production" ? [] : env === "preview" ? ["preview", version.branch] : ["local", ...(version.dirty ? ["dirty"] : [])]; + return ( + + + v{version.version} + + @ + + {version.shortSha} + + {rest.filter(Boolean).map((part) => ( + · {part} + ))} + + ); +} diff --git a/src/lib/version.ts b/src/lib/version.ts new file mode 100644 index 00000000..ee0673da --- /dev/null +++ b/src/lib/version.ts @@ -0,0 +1,44 @@ +export type AppVersion = { + version: string; + sha: string; + shortSha: string; + env: "production" | "preview" | "development" | "local"; + branch: string; + dirty: boolean; + builtAt: string; + releaseUrl: string; + commitUrl: string; +}; + +const repo = process.env.NEXT_PUBLIC_APP_REPO ?? ""; +const version = process.env.NEXT_PUBLIC_APP_VERSION ?? "0.0.0"; +const sha = process.env.NEXT_PUBLIC_APP_SHA ?? ""; + +export const appVersion: AppVersion = { + version, + sha, + shortSha: sha.slice(0, 7), + env: (process.env.NEXT_PUBLIC_APP_ENV as AppVersion["env"]) ?? "local", + branch: process.env.NEXT_PUBLIC_APP_BRANCH ?? "", + dirty: process.env.NEXT_PUBLIC_APP_DIRTY === "1", + builtAt: process.env.NEXT_PUBLIC_APP_BUILT_AT ?? "", + releaseUrl: `${repo}/releases/tag/v${version}`, + commitUrl: `${repo}/commit/${sha}`, +}; + +export type EnvTag = "production" | "preview" | "local"; +export const envTag = (v: AppVersion = appVersion): EnvTag => + v.env === "production" ? "production" : v.env === "preview" ? "preview" : "local"; + +/** "v1.1.0 @ b7595e4": the pill's main text (and the title for screen readers). */ +export function versionLabel(v: AppVersion = appVersion): string { + return `v${v.version} @ ${v.shortSha}`; +} + +/** Plain-text form for logs, meta and bug reports: "v1.1.0 @ 3f1addd (preview, feat/sing)". */ +export function versionText(v: AppVersion = appVersion): string { + const env = envTag(v); + if (env === "production") return versionLabel(v); + const extra = env === "preview" ? v.branch : v.dirty ? "dirty" : ""; + return `${versionLabel(v)} (${[env, extra].filter(Boolean).join(", ")})`; +} diff --git a/tests/e2e/version.spec.ts b/tests/e2e/version.spec.ts new file mode 100644 index 00000000..557a3ae8 --- /dev/null +++ b/tests/e2e/version.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from "./fixtures"; + +test("the footer and /api/version show the same version", async ({ page, request }) => { + const response = await request.get("/api/version"); + expect(response.ok()).toBe(true); + const api = await response.json(); + expect(api.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(api.shortSha).toBe(api.sha.slice(0, 7)); + + await page.goto("/privacy"); + // The Suspense fallback footer can still be in the DOM while streaming; take the visible one. + const footer = page.getByRole("contentinfo"); + const badge = footer.locator('[data-slot="version-badge"]'); + await expect(badge).toBeVisible(); + await expect(badge.getByRole("link", { name: `v${api.version}`, exact: true })).toHaveAttribute("href", api.releaseUrl); + await expect(badge.getByRole("link", { name: api.shortSha, exact: true })).toHaveAttribute("href", api.commitUrl); + await expect(badge).toContainText(`v${api.version} @ ${api.shortSha}`); + await expect(badge).toHaveAttribute("data-variant", "default"); + // Its own centred line at the bottom of the footer. + const [b, f] = [await badge.boundingBox(), await footer.boundingBox()]; + expect(Math.abs(b!.x + b!.width / 2 - (f!.x + f!.width / 2))).toBeLessThan(2); + expect(f!.y + f!.height - (b!.y + b!.height)).toBeLessThan(40); + const meta = await page.locator('meta[name="app-version"]').getAttribute("content"); + expect(meta).toContain(`v${api.version} @ ${api.shortSha}`); +}); diff --git a/tests/unit/lib/version.test.ts b/tests/unit/lib/version.test.ts new file mode 100644 index 00000000..63a4d6b0 --- /dev/null +++ b/tests/unit/lib/version.test.ts @@ -0,0 +1,70 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { VersionBadge } from "@/components/honeycomb/version-badge"; +import { envTag, versionLabel, versionText, type AppVersion } from "@/lib/version"; + +const base: AppVersion = { + version: "1.1.0", + sha: "b7595e4aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + shortSha: "b7595e4", + env: "production", + branch: "main", + dirty: false, + builtAt: "2026-10-23T00:00:00.000Z", + releaseUrl: "https://github.com/o/r/releases/tag/v1.1.0", + commitUrl: "https://github.com/o/r/commit/b7595e4", +}; + +describe("version text", () => { + it("formats production as the tag and hash only", () => { + expect(versionLabel(base)).toBe("v1.1.0 @ b7595e4"); + expect(versionText(base)).toBe("v1.1.0 @ b7595e4"); + }); + + it("adds the env and branch on preview", () => { + const v = { ...base, env: "preview" as const, shortSha: "3f1addd", branch: "feat/sing" }; + expect(versionText(v)).toBe("v1.1.0 @ 3f1addd (preview, feat/sing)"); + }); + + it("adds local, and dirty only with uncommitted changes", () => { + const clean = { ...base, env: "local" as const, shortSha: "3f1addd" }; + expect(versionText(clean)).toBe("v1.1.0 @ 3f1addd (local)"); + expect(versionText({ ...clean, dirty: true })).toBe("v1.1.0 @ 3f1addd (local, dirty)"); + }); + + it("maps Vercel's development env to local", () => { + expect(envTag({ ...base, env: "development" })).toBe("local"); + expect(envTag({ ...base, env: "production" })).toBe("production"); + expect(envTag({ ...base, env: "preview" })).toBe("preview"); + }); +}); + +describe("VersionBadge", () => { + const html = (env: AppVersion["env"]) => renderToStaticMarkup(createElement(VersionBadge, { version: { ...base, env, branch: "feat/sing", dirty: true } })); + const text = (out: string) => out.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim(); + + it("is the primary Badge on every env: one colour, mono, no marker, no brand or tint", () => { + for (const env of ["production", "preview", "local", "development"] as const) { + const out = html(env); + expect(out).toContain('data-variant="default"'); + expect(out).toContain("bg-primary"); + expect(out).toContain("font-mono"); + expect(out).not.toContain("size-2"); + expect(out).not.toContain("--tone"); + expect(out).not.toContain("--brand"); + } + }); + + it("reads tag @ hash on production, with links to the release and the commit", () => { + const out = html("production"); + expect(text(out)).toBe("v1.1.0 @ b7595e4"); + expect(out).toContain('href="https://github.com/o/r/releases/tag/v1.1.0"'); + expect(out).toContain('href="https://github.com/o/r/commit/b7595e4"'); + }); + + it("adds the env and branch on preview, and local with dirty", () => { + expect(text(html("preview"))).toBe("v1.1.0 @ b7595e4 · preview · feat/sing"); + expect(text(html("local"))).toBe("v1.1.0 @ b7595e4 · local · dirty"); + }); +});