From b90d724d1da75bd85f5ce738243b51923b27c461 Mon Sep 17 00:00:00 2001 From: Timo Klerx Date: Thu, 11 Jun 2026 14:51:05 +0200 Subject: [PATCH] feat: expose runtime build metadata --- .env.docker.example | 5 + .env.example | 8 ++ .github/workflows/deploy-azure.yml | 22 +++++ CONTINUE.md | 40 ++++---- CONTINUE_LOG.md | 8 ++ Dockerfile.app | 28 ++++++ Dockerfile.worker | 12 +++ README.md | 1 + docker-compose.yml | 18 ++++ infra/azure/main.tf | 5 + infra/azure/modules/runtime/app.tf | 25 +++++ infra/azure/modules/runtime/job.tf | 25 +++++ infra/azure/modules/runtime/variables.tf | 25 +++++ infra/azure/modules/runtime/worker.tf | 25 +++++ infra/azure/variables.tf | 24 +++++ specs/018-opentofu-azure-infra/quickstart.md | 21 ++++- src/app/api/version/route.ts | 6 ++ src/components/ui/AppVersionBadge.tsx | 2 +- src/lib/app-version.ts | 97 ++++++++++++++------ tests/unit/app-version.test.ts | 67 ++++++++++++++ tests/unit/security/deploy-workflow.test.ts | 15 +++ tests/unit/version-route.test.ts | 35 +++++++ 22 files changed, 466 insertions(+), 48 deletions(-) create mode 100644 src/app/api/version/route.ts create mode 100644 tests/unit/app-version.test.ts create mode 100644 tests/unit/version-route.test.ts diff --git a/.env.docker.example b/.env.docker.example index 031a39c..de7f36a 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -11,6 +11,11 @@ WORKER_DATABASE_URL=postgresql://starter:replace-with-a-real-docker-secret@postg MIGRATION_DATABASE_URL=postgresql://starter:replace-with-a-real-docker-secret@postgres:5432/business_app_starter?connection_limit=2 BASE_PATH=/webapp-template +APP_ENVIRONMENT=local +APP_VERSION=local +APP_REVISION=unknown +APP_BUILD_ID=docker-compose +APP_BUILT_AT=unknown PORT=3270 AUTH_BASE_URL=http://localhost:3270/webapp-template BETTER_AUTH_SECRET=replace-with-at-least-32-characters diff --git a/.env.example b/.env.example index a6c609c..a7f7376 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,14 @@ PORT=3270 # App base path for local and reverse-proxied deployments. BASE_PATH= +# Non-secret build/deployment identity displayed in the UI and /api/version. +# CI should set these from the commit, run id, and release/tag metadata. +APP_ENVIRONMENT=local +APP_VERSION=local +APP_REVISION=unknown +APP_BUILD_ID=local +APP_BUILT_AT=unknown + # Public origin used to build BetterAuth callback and post-login URLs. AUTH_BASE_URL=http://localhost:3270 diff --git a/.github/workflows/deploy-azure.yml b/.github/workflows/deploy-azure.yml index 9b51b44..6b13ecc 100644 --- a/.github/workflows/deploy-azure.yml +++ b/.github/workflows/deploy-azure.yml @@ -126,6 +126,28 @@ jobs: echo "migration_repository=$MIGRATION_IMAGE_REPOSITORY" } >> "$GITHUB_OUTPUT" + - name: Export build metadata + shell: bash + env: + TARGET_ENVIRONMENT: ${{ inputs.environment }} + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + + if [[ "$REF_TYPE" == "tag" ]]; then + app_version="$REF_NAME" + else + app_version="$TARGET_ENVIRONMENT-$GITHUB_RUN_NUMBER" + fi + + { + echo "TF_VAR_app_version=$app_version" + echo "TF_VAR_app_revision=$GITHUB_SHA" + echo "TF_VAR_app_build_id=$GITHUB_RUN_ID.$GITHUB_RUN_ATTEMPT" + echo "TF_VAR_app_built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } >> "$GITHUB_ENV" + - name: Provision infrastructure id: provision shell: bash diff --git a/CONTINUE.md b/CONTINUE.md index c0c9850..b3ce0c1 100644 --- a/CONTINUE.md +++ b/CONTINUE.md @@ -4,33 +4,39 @@ ## Current Snapshot -- Updated: 2026-06-11 11:06:34 -- Branch: `020-deploy-smoke-verification` +- Updated: 2026-06-11 14:46:32 +- Branch: `main` ## Recent Non-Continuity Commits +- 6c81729 feat: add azure deployment smoke verification (#3) - 3d52264 fix: move state queue logging to dedicated resource - 25306fd chore: refresh specs overview - dd226de test: update opentofu action pin assertion - 9b92cb5 ci: update opentofu setup action -- fdf3418 chore: close completed active specs ## Git Status +- M .env.docker.example +- M .env.example - M .github/workflows/deploy-azure.yml -- M .specify/feature.json -- M ACTIVE_SPECS.md -- M AGENTS.md -- M package.json +- M Dockerfile.app +- M Dockerfile.worker +- M README.md +- M docker-compose.yml +- M infra/azure/main.tf +- M infra/azure/modules/runtime/app.tf +- M infra/azure/modules/runtime/job.tf +- M infra/azure/modules/runtime/variables.tf +- M infra/azure/modules/runtime/worker.tf +- M infra/azure/variables.tf - M specs/018-opentofu-azure-infra/quickstart.md -- M specs/OVERVIEW.md +- M src/components/ui/AppVersionBadge.tsx +- M src/lib/app-version.ts - M tests/unit/security/deploy-workflow.test.ts -- ?? docs/azure-deploy-smoke.md -- ?? scripts/azure-deploy-smoke.ts -- ?? scripts/run-azure-deploy-smoke.mjs -- ?? specs/020-deploy-smoke-verification/ -- ?? tests/integration/azure-deploy-smoke-cli.test.ts -- ?? tests/unit/azure-deploy-smoke.test.ts +- ?? src/app/api/version/ +- ?? tests/unit/app-version.test.ts +- ?? tests/unit/version-route.test.ts ## Active Specs @@ -38,6 +44,6 @@ ## Next Recommended Actions -1. Commit and push `020-deploy-smoke-verification`. -2. Open a pull request for the deployment smoke verification feature. -3. Confirm GitHub Actions validation, then merge if green. +1. Review, commit, and push the runtime build metadata changes. +2. Optionally open a PR and confirm GitHub Actions validation. +3. Use `APP_ENVIRONMENT`, `APP_VERSION`, `APP_REVISION`, `APP_BUILD_ID`, and `APP_BUILT_AT` for dev/staging traceability instead of generated version files. diff --git a/CONTINUE_LOG.md b/CONTINUE_LOG.md index d7085b3..240fcff 100644 --- a/CONTINUE_LOG.md +++ b/CONTINUE_LOG.md @@ -1509,3 +1509,11 @@ - Validation passed: focused smoke/workflow tests and `.\validate.ps1 all`. - Active specs: none. - Next focus: commit/push the feature branch, open a PR, and confirm GitHub Actions validation. + +## 2026-06-11 14:46:32 + +- Added env-driven build/deployment metadata for app version traceability without committing generated version files. +- Added `/api/version`, updated the UI version badge, Docker image labels/build args, Docker Compose env, Azure runtime env, and deploy workflow metadata export. +- Validation passed: focused version/workflow tests, `pnpm run typecheck`, and `.\validate.ps1 all`. +- Active specs: none. +- Next focus: review, commit, push, and optionally open a PR for the metadata changes. diff --git a/Dockerfile.app b/Dockerfile.app index 041e374..8e6fa8d 100644 --- a/Dockerfile.app +++ b/Dockerfile.app @@ -22,7 +22,15 @@ RUN pnpm install --prod FROM base AS builder WORKDIR /app ARG BASE_PATH +ARG APP_VERSION +ARG APP_REVISION +ARG APP_BUILD_ID +ARG APP_BUILT_AT ENV BASE_PATH=$BASE_PATH +ENV APP_VERSION=$APP_VERSION +ENV APP_REVISION=$APP_REVISION +ENV APP_BUILD_ID=$APP_BUILD_ID +ENV APP_BUILT_AT=$APP_BUILT_AT ENV AUTH_BASE_URL=http://localhost:3270 ENV BETTER_AUTH_SECRET=docker-build-secret-change-me-at-least-32-characters ENV APP_DATABASE_URL=postgresql://starter:starter@localhost:5432/business_app_starter @@ -33,6 +41,14 @@ RUN pnpm exec prisma generate --config prisma.config.postgres.ts && pnpm run bui FROM base AS migrate-runner WORKDIR /app +ARG APP_VERSION +ARG APP_REVISION +ARG APP_BUILD_ID +ARG APP_BUILT_AT +LABEL org.opencontainers.image.version=$APP_VERSION +LABEL org.opencontainers.image.revision=$APP_REVISION +LABEL org.opencontainers.image.created=$APP_BUILT_AT +LABEL org.opencontainers.image.source="https://github.com/TKlerx/webapp-template" COPY --from=migrate-deps /migrate-runtime/node_modules ./node_modules COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/prisma ./prisma @@ -42,9 +58,21 @@ COPY --from=builder /app/scripts ./scripts FROM base AS runner WORKDIR /app +ARG APP_VERSION +ARG APP_REVISION +ARG APP_BUILD_ID +ARG APP_BUILT_AT +LABEL org.opencontainers.image.version=$APP_VERSION +LABEL org.opencontainers.image.revision=$APP_REVISION +LABEL org.opencontainers.image.created=$APP_BUILT_AT +LABEL org.opencontainers.image.source="https://github.com/TKlerx/webapp-template" ENV NODE_ENV=production ENV PORT=3270 ENV HOSTNAME=0.0.0.0 +ENV APP_VERSION=$APP_VERSION +ENV APP_REVISION=$APP_REVISION +ENV APP_BUILD_ID=$APP_BUILD_ID +ENV APP_BUILT_AT=$APP_BUILT_AT COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/generated ./generated diff --git a/Dockerfile.worker b/Dockerfile.worker index 1f31944..e87de09 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -1,6 +1,18 @@ FROM python:3.12-slim WORKDIR /worker +ARG APP_VERSION +ARG APP_REVISION +ARG APP_BUILD_ID +ARG APP_BUILT_AT +LABEL org.opencontainers.image.version=$APP_VERSION +LABEL org.opencontainers.image.revision=$APP_REVISION +LABEL org.opencontainers.image.created=$APP_BUILT_AT +LABEL org.opencontainers.image.source="https://github.com/TKlerx/webapp-template" +ENV APP_VERSION=$APP_VERSION +ENV APP_REVISION=$APP_REVISION +ENV APP_BUILD_ID=$APP_BUILD_ID +ENV APP_BUILT_AT=$APP_BUILT_AT RUN apt-get update -y \ && apt-get upgrade -y \ diff --git a/README.md b/README.md index 59ce288..501e72b 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ That guide covers: - Structured JSON logs are emitted through `src/lib/logger.ts` and redact common secrets automatically. See [`docs/logging.md`](./docs/logging.md) for event naming, safe metadata, worker logging, and guardrail rules. - Runtime process failures are captured in `src/instrumentation.ts`. - Health checks are exposed at `/api/health` with process and database status. +- Build metadata is exposed at `/api/version` and shown in the app badge from `APP_ENVIRONMENT`, `APP_VERSION`, `APP_REVISION`, `APP_BUILD_ID`, and `APP_BUILT_AT`; CI/deploys should set these from the commit, run id, and release/tag instead of committing generated version files. - `LOG_LEVEL` controls severity filtering. `ENABLE_REQUEST_LOGGING=true` enables opt-in request completion logs. ## Dependency Safety diff --git a/docker-compose.yml b/docker-compose.yml index 3108e3d..4edb1ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,10 @@ x-app-image: &app-image target: runner args: BASE_PATH: ${BASE_PATH} + APP_VERSION: ${APP_VERSION:-local} + APP_REVISION: ${APP_REVISION:-unknown} + APP_BUILD_ID: ${APP_BUILD_ID:-docker-compose} + APP_BUILT_AT: ${APP_BUILT_AT:-unknown} x-migrate-image: &migrate-image image: ${MIGRATE_IMAGE_NAME:-business-app-starter-migrate}:latest @@ -15,9 +19,18 @@ x-migrate-image: &migrate-image target: migrate-runner args: BASE_PATH: ${BASE_PATH} + APP_VERSION: ${APP_VERSION:-local} + APP_REVISION: ${APP_REVISION:-unknown} + APP_BUILD_ID: ${APP_BUILD_ID:-docker-compose} + APP_BUILT_AT: ${APP_BUILT_AT:-unknown} x-common-env: &common-env AUTH_BASE_URL: ${AUTH_BASE_URL:-http://localhost:3270} + APP_BUILD_ID: ${APP_BUILD_ID:-docker-compose} + APP_BUILT_AT: ${APP_BUILT_AT:-unknown} + APP_ENVIRONMENT: ${APP_ENVIRONMENT:-local} + APP_REVISION: ${APP_REVISION:-unknown} + APP_VERSION: ${APP_VERSION:-local} BASE_PATH: ${BASE_PATH:-} ENABLE_REQUEST_LOGGING: ${ENABLE_REQUEST_LOGGING:-true} LOG_LEVEL: ${LOG_LEVEL:-info} @@ -139,6 +152,11 @@ services: build: context: . dockerfile: Dockerfile.worker + args: + APP_VERSION: ${APP_VERSION:-local} + APP_REVISION: ${APP_REVISION:-unknown} + APP_BUILD_ID: ${APP_BUILD_ID:-docker-compose} + APP_BUILT_AT: ${APP_BUILT_AT:-unknown} environment: <<: *worker-env networks: [internal] diff --git a/infra/azure/main.tf b/infra/azure/main.tf index 6acabf5..f23edc7 100644 --- a/infra/azure/main.tf +++ b/infra/azure/main.tf @@ -114,6 +114,11 @@ module "runtime" { app_min_replicas = var.app_min_replicas app_max_replicas = var.app_max_replicas worker_min_replicas = var.worker_min_replicas + app_environment = var.environment + app_version = var.app_version + app_revision = var.app_revision + app_build_id = var.app_build_id + app_built_at = var.app_built_at base_path = var.base_path custom_domain = var.custom_domain enable_mail = var.enable_mail diff --git a/infra/azure/modules/runtime/app.tf b/infra/azure/modules/runtime/app.tf index 23187dd..8782797 100644 --- a/infra/azure/modules/runtime/app.tf +++ b/infra/azure/modules/runtime/app.tf @@ -75,6 +75,31 @@ resource "azurerm_container_app" "app" { value = var.base_path } + env { + name = "APP_ENVIRONMENT" + value = var.app_environment + } + + env { + name = "APP_VERSION" + value = var.app_version + } + + env { + name = "APP_REVISION" + value = var.app_revision + } + + env { + name = "APP_BUILD_ID" + value = var.app_build_id + } + + env { + name = "APP_BUILT_AT" + value = var.app_built_at + } + env { name = "AUTH_BASE_URL" value = local.auth_base_url diff --git a/infra/azure/modules/runtime/job.tf b/infra/azure/modules/runtime/job.tf index 0ff40ae..2ff2e93 100644 --- a/infra/azure/modules/runtime/job.tf +++ b/infra/azure/modules/runtime/job.tf @@ -81,6 +81,31 @@ resource "azurerm_container_app_job" "migration" { value = "prisma.config.postgres.ts" } + env { + name = "APP_ENVIRONMENT" + value = var.app_environment + } + + env { + name = "APP_VERSION" + value = var.app_version + } + + env { + name = "APP_REVISION" + value = var.app_revision + } + + env { + name = "APP_BUILD_ID" + value = var.app_build_id + } + + env { + name = "APP_BUILT_AT" + value = var.app_built_at + } + env { name = "DATABASE_URL" secret_name = "migration-database-url" diff --git a/infra/azure/modules/runtime/variables.tf b/infra/azure/modules/runtime/variables.tf index 5788da5..d85e189 100644 --- a/infra/azure/modules/runtime/variables.tf +++ b/infra/azure/modules/runtime/variables.tf @@ -69,6 +69,31 @@ variable "worker_min_replicas" { type = number } +variable "app_environment" { + description = "Non-secret application environment label." + type = string +} + +variable "app_version" { + description = "Non-secret application version label." + type = string +} + +variable "app_revision" { + description = "Non-secret source revision." + type = string +} + +variable "app_build_id" { + description = "Non-secret CI/build identifier." + type = string +} + +variable "app_built_at" { + description = "Non-secret build timestamp." + type = string +} + variable "base_path" { description = "Application base path." type = string diff --git a/infra/azure/modules/runtime/worker.tf b/infra/azure/modules/runtime/worker.tf index 3c1d519..b4ff22b 100644 --- a/infra/azure/modules/runtime/worker.tf +++ b/infra/azure/modules/runtime/worker.tf @@ -47,6 +47,31 @@ resource "azurerm_container_app" "worker" { value = var.base_path } + env { + name = "APP_ENVIRONMENT" + value = var.app_environment + } + + env { + name = "APP_VERSION" + value = var.app_version + } + + env { + name = "APP_REVISION" + value = var.app_revision + } + + env { + name = "APP_BUILD_ID" + value = var.app_build_id + } + + env { + name = "APP_BUILT_AT" + value = var.app_built_at + } + env { name = "WORKER_DATABASE_URL" secret_name = "worker-database-url" diff --git a/infra/azure/variables.tf b/infra/azure/variables.tf index 4ffc537..fb2c6c5 100644 --- a/infra/azure/variables.tf +++ b/infra/azure/variables.tf @@ -66,6 +66,30 @@ variable "base_path" { default = "/app-starter" } +variable "app_version" { + description = "Non-secret application version label displayed by the app and exposed by /api/version." + type = string + default = "" +} + +variable "app_revision" { + description = "Non-secret source revision displayed by the app and exposed by /api/version." + type = string + default = "" +} + +variable "app_build_id" { + description = "Non-secret CI/build identifier displayed by the app and exposed by /api/version." + type = string + default = "" +} + +variable "app_built_at" { + description = "Non-secret build timestamp displayed by the app and exposed by /api/version." + type = string + default = "" +} + variable "custom_domain" { description = "Optional custom domain. Empty uses the default Container Apps FQDN." type = string diff --git a/specs/018-opentofu-azure-infra/quickstart.md b/specs/018-opentofu-azure-infra/quickstart.md index 903ca58..e72172d 100644 --- a/specs/018-opentofu-azure-infra/quickstart.md +++ b/specs/018-opentofu-azure-infra/quickstart.md @@ -29,9 +29,24 @@ The shared ACR (from Step 0) must contain the images before any environment's ru ```bash az acr login --name -docker build -f Dockerfile.app -t /app: . -docker build -f Dockerfile.app --target migrate-runner -t /app: . -docker build -f Dockerfile.worker -t /worker: . +docker build -f Dockerfile.app \ + --build-arg APP_VERSION= \ + --build-arg APP_REVISION= \ + --build-arg APP_BUILD_ID= \ + --build-arg APP_BUILT_AT= \ + -t /app: . +docker build -f Dockerfile.app --target migrate-runner \ + --build-arg APP_VERSION= \ + --build-arg APP_REVISION= \ + --build-arg APP_BUILD_ID= \ + --build-arg APP_BUILT_AT= \ + -t /app: . +docker build -f Dockerfile.worker \ + --build-arg APP_VERSION= \ + --build-arg APP_REVISION= \ + --build-arg APP_BUILD_ID= \ + --build-arg APP_BUILT_AT= \ + -t /worker: . docker push /app: docker push /app: docker push /worker: diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts new file mode 100644 index 0000000..f5917b0 --- /dev/null +++ b/src/app/api/version/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from "next/server"; +import { getAppVersionInfo } from "@/lib/app-version"; + +export function GET() { + return NextResponse.json(getAppVersionInfo()); +} diff --git a/src/components/ui/AppVersionBadge.tsx b/src/components/ui/AppVersionBadge.tsx index cffa3ee..0450989 100644 --- a/src/components/ui/AppVersionBadge.tsx +++ b/src/components/ui/AppVersionBadge.tsx @@ -2,7 +2,7 @@ import { getAppVersionLabel } from "@/lib/app-version"; export function AppVersionBadge() { return ( -
+
{getAppVersionLabel()}
); diff --git a/src/lib/app-version.ts b/src/lib/app-version.ts index 73db07a..14bc208 100644 --- a/src/lib/app-version.ts +++ b/src/lib/app-version.ts @@ -1,38 +1,81 @@ -import { execFileSync } from "node:child_process"; import packageInfo from "../../package.json"; -let cachedVersionLabel: string | null = null; - -function getShortGitHash() { - try { - return execFileSync("git", ["rev-parse", "--short", "HEAD"], { - cwd: process.cwd(), - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - } catch { - return ""; +export type AppVersionInfo = { + environment: string; + version: string; + revision: string; + shortRevision: string; + buildId: string; + builtAt: string; + label: string; +}; + +const UNKNOWN = "unknown"; +const LOCAL_ENVIRONMENT = "local"; + +let cachedVersionInfo: AppVersionInfo | null = null; + +export function getAppVersionInfo() { + if (cachedVersionInfo) { + return cachedVersionInfo; } + + const environment = readMetadata("APP_ENVIRONMENT") || LOCAL_ENVIRONMENT; + const version = readMetadata("APP_VERSION") || `v${packageInfo.version}`; + const revision = + readMetadata("APP_REVISION") || readMetadata("APP_GIT_SHA") || UNKNOWN; + const shortRevision = revision === UNKNOWN ? UNKNOWN : revision.slice(0, 12); + const buildId = readMetadata("APP_BUILD_ID") || UNKNOWN; + const builtAt = readMetadata("APP_BUILT_AT") || UNKNOWN; + + cachedVersionInfo = { + environment, + version, + revision, + shortRevision, + buildId, + builtAt, + label: formatAppVersionLabel({ + environment, + version, + shortRevision, + buildId, + }), + }; + + return cachedVersionInfo; } export function getAppVersionLabel() { - if (cachedVersionLabel) { - return cachedVersionLabel; - } + return getAppVersionInfo().label; +} + +export function resetAppVersionInfoForTests() { + cachedVersionInfo = null; +} - const baseVersion = `v${packageInfo.version}`; - const configuredHash = process.env.APP_GIT_SHA?.trim(); +function readMetadata(name: string) { + return process.env[name]?.trim() ?? ""; +} + +function formatAppVersionLabel({ + environment, + version, + shortRevision, + buildId, +}: Pick< + AppVersionInfo, + "environment" | "version" | "shortRevision" | "buildId" +>) { + const parts = [environment, version]; + + if (shortRevision !== UNKNOWN) { + parts.push(shortRevision); + } - if (process.env.NODE_ENV !== "production") { - const gitHash = configuredHash || getShortGitHash(); - cachedVersionLabel = gitHash - ? `${baseVersion}+${gitHash}-dev` - : `${baseVersion}-dev`; - return cachedVersionLabel; + if (buildId !== UNKNOWN) { + parts.push(`run ${buildId}`); } - cachedVersionLabel = configuredHash - ? `${baseVersion}+${configuredHash}` - : baseVersion; - return cachedVersionLabel; + return parts.join(" | "); } diff --git a/tests/unit/app-version.test.ts b/tests/unit/app-version.test.ts new file mode 100644 index 0000000..73270a1 --- /dev/null +++ b/tests/unit/app-version.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + getAppVersionInfo, + getAppVersionLabel, + resetAppVersionInfoForTests, +} from "@/lib/app-version"; + +const metadataKeys = [ + "APP_ENVIRONMENT", + "APP_VERSION", + "APP_REVISION", + "APP_GIT_SHA", + "APP_BUILD_ID", + "APP_BUILT_AT", +]; + +describe("app version metadata", () => { + afterEach(() => { + for (const key of metadataKeys) { + delete process.env[key]; + } + resetAppVersionInfoForTests(); + }); + + it("formats explicit deployment metadata for the UI badge", () => { + process.env.APP_ENVIRONMENT = "staging"; + process.env.APP_VERSION = "staging-42"; + process.env.APP_REVISION = "abcdef1234567890"; + process.env.APP_BUILD_ID = "123.2"; + process.env.APP_BUILT_AT = "2026-06-11T09:20:17Z"; + + expect(getAppVersionInfo()).toMatchObject({ + environment: "staging", + version: "staging-42", + revision: "abcdef1234567890", + shortRevision: "abcdef123456", + buildId: "123.2", + builtAt: "2026-06-11T09:20:17Z", + label: "staging | staging-42 | abcdef123456 | run 123.2", + }); + expect(getAppVersionLabel()).toBe( + "staging | staging-42 | abcdef123456 | run 123.2", + ); + }); + + it("falls back to package version and unknown revision without shelling out", () => { + expect(getAppVersionInfo()).toMatchObject({ + environment: "local", + version: "v1.0.0", + revision: "unknown", + shortRevision: "unknown", + buildId: "unknown", + builtAt: "unknown", + label: "local | v1.0.0", + }); + }); + + it("accepts legacy APP_GIT_SHA as the revision fallback", () => { + process.env.APP_GIT_SHA = "1122334455667788"; + + expect(getAppVersionInfo()).toMatchObject({ + revision: "1122334455667788", + shortRevision: "112233445566", + label: "local | v1.0.0 | 112233445566", + }); + }); +}); diff --git a/tests/unit/security/deploy-workflow.test.ts b/tests/unit/security/deploy-workflow.test.ts index b7a53ef..0c110c7 100644 --- a/tests/unit/security/deploy-workflow.test.ts +++ b/tests/unit/security/deploy-workflow.test.ts @@ -69,6 +69,21 @@ describe("Azure deploy workflow contract", () => { expect(workflow).toContain("pnpm run smoke:azure"); }); + it("exports non-secret build metadata before provisioning", () => { + const workflow = fs.readFileSync(WORKFLOW_PATH, "utf8"); + + expect(workflow).toContain("Export build metadata"); + expect(workflow.indexOf("Export build metadata")).toBeLessThan( + workflow.indexOf("id: provision"), + ); + expect(workflow).toContain("TF_VAR_app_version=$app_version"); + expect(workflow).toContain("TF_VAR_app_revision=$GITHUB_SHA"); + expect(workflow).toContain( + "TF_VAR_app_build_id=$GITHUB_RUN_ID.$GITHUB_RUN_ATTEMPT", + ); + expect(workflow).toContain("TF_VAR_app_built_at=$(date -u"); + }); + it("blocks promotion when migration fails and reports non-promotion", () => { const workflow = fs.readFileSync(WORKFLOW_PATH, "utf8"); diff --git a/tests/unit/version-route.test.ts b/tests/unit/version-route.test.ts new file mode 100644 index 0000000..f6698d4 --- /dev/null +++ b/tests/unit/version-route.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { resetAppVersionInfoForTests } from "@/lib/app-version"; +import { GET } from "@/app/api/version/route"; + +describe("version route", () => { + afterEach(() => { + delete process.env.APP_ENVIRONMENT; + delete process.env.APP_VERSION; + delete process.env.APP_REVISION; + delete process.env.APP_BUILD_ID; + delete process.env.APP_BUILT_AT; + resetAppVersionInfoForTests(); + }); + + it("returns non-secret build metadata", async () => { + process.env.APP_ENVIRONMENT = "dev"; + process.env.APP_VERSION = "dev-17"; + process.env.APP_REVISION = "abcdef123456"; + process.env.APP_BUILD_ID = "987.1"; + process.env.APP_BUILT_AT = "2026-06-11T09:20:17Z"; + + const response = GET(); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + environment: "dev", + version: "dev-17", + revision: "abcdef123456", + shortRevision: "abcdef123456", + buildId: "987.1", + builtAt: "2026-06-11T09:20:17Z", + label: "dev | dev-17 | abcdef123456 | run 987.1", + }); + }); +});