Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions scripts/check-version-tag.mjs
Original file line number Diff line number Diff line change
@@ -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.`);
5 changes: 5 additions & 0 deletions src/app/api/version/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<"/">) {
Expand Down
6 changes: 5 additions & 1 deletion src/components/honeycomb/site-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
*/
Expand Down Expand Up @@ -73,6 +74,9 @@ export function SiteFooterView({ repo = REPO, dark = false, night = false }: { r
</a>
</span>
</div>
<div className="flex justify-center md:col-span-3">
<VersionBadge />
</div>
</div>
</footer>
);
Expand Down
26 changes: 26 additions & 0 deletions src/components/honeycomb/version-badge.tsx
Original file line number Diff line number Diff line change
@@ -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 · <branch>` 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 (
<Badge variant="default" data-slot="version-badge" data-env={env} className={`gap-0 whitespace-pre-wrap normal-case tracking-normal ${className ?? ""}`.trim()}>
<a className="hover:underline" href={version.releaseUrl}>
v{version.version}
</a>
<span> @ </span>
<a className="hover:underline" href={version.commitUrl}>
{version.shortSha}
</a>
{rest.filter(Boolean).map((part) => (
<span key={part}> · {part}</span>
))}
</Badge>
);
}
44 changes: 44 additions & 0 deletions src/lib/version.ts
Original file line number Diff line number Diff line change
@@ -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(", ")})`;
}
25 changes: 25 additions & 0 deletions tests/e2e/version.spec.ts
Original file line number Diff line number Diff line change
@@ -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}`);
});
70 changes: 70 additions & 0 deletions tests/unit/lib/version.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading