From f131d6be2c03508706aacebebe7a9d65611a321a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 12:17:40 +0200 Subject: [PATCH] feat(api): public /api/version endpoint for the v1.4 About surface (#128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a public GET endpoint that exposes the running build's version, optional Git SHA, optional ISO build timestamp, license, and canonical links (repository, changelog, docs site). Three consumers already lined up against this: - The Settings → About section (v1.4 plan A3) renders version + build metadata + a "Check for updates" button. - The footer of every page can show the version dezent without another roundtrip. - The "Check for updates" button compares the returned version against the GHCR API. Build-time env vars (`NEXT_PUBLIC_APP_BUILD_SHA`, `NEXT_PUBLIC_APP_BUILT_AT`) are read straight off `process.env` — the existing `docker-publish.yml` workflow can wire them once it needs to. Local `pnpm dev` returns `null` for both fields and the UI falls back to "development" wording. Static export (`force-static`) so the endpoint adds zero load on the DB and survives in the static-cache page chunks. Tests: 395 → 400 (+3 for the version envelope shape and env-var plumbing). The remaining About UI lands in a follow-up so this PR stays scoped to the API contract. Co-authored-by: Marc-André Bombeck --- src/app/api/version/__tests__/route.test.ts | 52 +++++++++++++++++++++ src/app/api/version/route.ts | 39 ++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/app/api/version/__tests__/route.test.ts create mode 100644 src/app/api/version/route.ts diff --git a/src/app/api/version/__tests__/route.test.ts b/src/app/api/version/__tests__/route.test.ts new file mode 100644 index 000000000..f21c9561f --- /dev/null +++ b/src/app/api/version/__tests__/route.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, +})); + +import { GET } from "../route"; + +beforeEach(() => { + delete process.env.NEXT_PUBLIC_APP_BUILD_SHA; + delete process.env.NEXT_PUBLIC_APP_BUILT_AT; +}); + +interface VersionEnvelope { + data: { + version: string; + buildSha: string | null; + builtAt: string | null; + license: string; + repository: string; + changelog: string; + docs: string; + }; +} + +describe("GET /api/version", () => { + it("returns the package.json version", async () => { + const response = await (GET as unknown as () => Promise)(); + const body = (await response.json()) as VersionEnvelope; + expect(body.data.version).toMatch(/^\d+\.\d+\.\d+(-[a-z0-9.-]+)?$/i); + expect(body.data.license).toBe("AGPL-3.0"); + expect(body.data.repository).toContain("github.com"); + expect(body.data.changelog).toContain("CHANGELOG"); + expect(body.data.docs).toContain("docs.healthlog.dev"); + }); + + it("returns null buildSha / builtAt for a `pnpm dev` build", async () => { + const response = await (GET as unknown as () => Promise)(); + const body = (await response.json()) as VersionEnvelope; + expect(body.data.buildSha).toBeNull(); + expect(body.data.builtAt).toBeNull(); + }); + + it("surfaces the build SHA and timestamp when the env vars are set", async () => { + process.env.NEXT_PUBLIC_APP_BUILD_SHA = "abc1234"; + process.env.NEXT_PUBLIC_APP_BUILT_AT = "2026-05-08T12:00:00.000Z"; + const response = await (GET as unknown as () => Promise)(); + const body = (await response.json()) as VersionEnvelope; + expect(body.data.buildSha).toBe("abc1234"); + expect(body.data.builtAt).toBe("2026-05-08T12:00:00.000Z"); + }); +}); diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts new file mode 100644 index 000000000..7a9fc8371 --- /dev/null +++ b/src/app/api/version/route.ts @@ -0,0 +1,39 @@ +import { apiHandler } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import packageJson from "../../../../package.json"; + +export const dynamic = "force-static"; +export const revalidate = false; + +/** + * GET /api/version + * + * Public endpoint exposing the running build's version. Used by the + * Settings → About surface, the footer of every page, and the + * "Check for updates" button (which compares against the GHCR API). + * + * The build SHA and built-at timestamp come from environment variables + * baked at image build time: + * - `NEXT_PUBLIC_APP_BUILD_SHA` — short Git SHA, set by the + * `docker-publish` workflow. + * - `NEXT_PUBLIC_APP_BUILT_AT` — ISO-8601 build timestamp, same + * workflow. + * + * For local `pnpm dev` neither is set; the route returns `null` and + * the UI falls back to "development" wording. + */ +export const GET = apiHandler(async () => { + const version = packageJson.version; + const buildSha = process.env.NEXT_PUBLIC_APP_BUILD_SHA?.trim() || null; + const builtAt = process.env.NEXT_PUBLIC_APP_BUILT_AT?.trim() || null; + + return apiSuccess({ + version, + buildSha, + builtAt, + license: "AGPL-3.0", + repository: "https://github.com/MBombeck/HealthLog", + changelog: "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md", + docs: "https://docs.healthlog.dev", + }); +});