From 4ee7ec71f00e068a37bb25657bc44760ac0bc02c Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Thu, 30 Jul 2026 11:41:12 -0700 Subject: [PATCH] feat(deploy): prisma migration history, /version, galactus standalone deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap between "what tag did I deploy" and "what is actually running", and gives the schema a history that can be reasoned about across releases. Migrations - Baseline the existing schema as 0000_init (migrate diff --from-empty). The schema had only ever been applied with `prisma db push`, so no history existed and schema state was disconnected from app version. Existing databases must be baselined once with `migrate resolve --applied 0000_init`; the workflows print this remedy on P3005. - Run `prisma migrate deploy` as a deploy STEP, not the container CMD — as a CMD, N replicas would race each other applying the same migration. Version reporting - GET /version on the API reports the APP_VERSION / GIT_SHA / BUILD_DATE that build.yml already baked into both images but nothing ever read. - The web footer shows the web build and flags an api/web mismatch. The two cannot drift at build time (one matrix run) but can at deploy time. - Both deploy workflows now fail if the running API does not report the tag that was dispatched — a stack naming a tag is not proof of what is running. - scripts/set-version.mjs stamps every package.json, which had all sat at 0.1.0 while real releases shipped as v1.x. Pre-migrate backup - deploy/scripts/pre-migrate-backup.mjs dumps the database from INSIDE the still-running old API container over Portainer's Docker API, so the file lands in the volume the Operaciones restore screen reads. A dump taken on the CI runner would be unreachable by the only restore path we have. Verifies the artefact with `gzip -t` before letting the migration proceed. galactus - deploy/galactus/*.compose.yml: standalone-Docker ports of the Swarm stacks. Plain compose silently ignores `deploy:`, so restart_policy becomes `restart: unless-stopped` — without it nothing returns after a host reboot. - .gitea/workflows/deploy-galactus.yml drives endpoint 3 with its own secrets. Fixes - deploy.yml passed `endpoint_id` and `pull_image` to cssnr/portainer-stack-deploy-action, which has no such inputs (they are `endpoint` and `pull`). The endpoint was silently never set. docs/DEPLOY_AND_MIGRATIONS.md documents expand/contract as the rule for schema changes: Prisma has no down-migrations, so a code rollback never rolls the schema back, and restoring the replication master from a dump diverges every replica. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/deploy-galactus.yml | 228 ++++++++ .gitea/workflows/deploy.yml | 115 +++- apps/api/src/app.controller.ts | 21 + apps/web/src/app/globals.css | 32 ++ apps/web/src/app/layout.tsx | 8 +- apps/web/src/components/AppShell.tsx | 50 +- apps/web/src/lib/api.ts | 12 + apps/web/src/lib/build-info.ts | 42 ++ deploy/galactus/jorgecuadros-app.compose.yml | 81 +++ deploy/galactus/jorgecuadros-db.compose.yml | 64 +++ .../galactus/jorgecuadros-minio.compose.yml | 32 ++ deploy/jorgecuadros-app.stack.yml | 7 + deploy/scripts/pre-migrate-backup.mjs | 206 +++++++ docs/DEPLOY_AND_MIGRATIONS.md | 182 ++++++ package.json | 4 +- .../prisma/migrations/0000_init/migration.sql | 538 ++++++++++++++++++ .../prisma/migrations/migration_lock.toml | 3 + scripts/set-version.mjs | 60 ++ 18 files changed, 1677 insertions(+), 8 deletions(-) create mode 100644 .gitea/workflows/deploy-galactus.yml create mode 100644 apps/web/src/lib/build-info.ts create mode 100644 deploy/galactus/jorgecuadros-app.compose.yml create mode 100644 deploy/galactus/jorgecuadros-db.compose.yml create mode 100644 deploy/galactus/jorgecuadros-minio.compose.yml create mode 100644 deploy/scripts/pre-migrate-backup.mjs create mode 100644 docs/DEPLOY_AND_MIGRATIONS.md create mode 100644 packages/database/prisma/migrations/0000_init/migration.sql create mode 100644 packages/database/prisma/migrations/migration_lock.toml create mode 100644 scripts/set-version.mjs diff --git a/.gitea/workflows/deploy-galactus.yml b/.gitea/workflows/deploy-galactus.yml new file mode 100644 index 0000000..243ee92 --- /dev/null +++ b/.gitea/workflows/deploy-galactus.yml @@ -0,0 +1,228 @@ +# Manual PROD deploy to galactus — the office server, Portainer endpoint 3. +# +# galactus is STANDALONE Docker (`swarm: inactive`), so this workflow applies +# the compose files under deploy/galactus/, NOT the Swarm files in deploy/. +# .gitea/workflows/deploy.yml is the cubex/Swarm equivalent; the two are kept +# separate on purpose because plain compose silently ignores Swarm's `deploy:` +# keys rather than failing on them. +# +# This does NOT build. build.yml already built + pushed both images from one +# matrix run, so api and web at the same tag are always in step. +# +# Order of operations, and why: +# 1. db + minio (scope=full only) — the API depends on both. +# 2. pre-migrate backup dumped INSIDE the still-running OLD api container, +# so the file lands in the volume the Operaciones +# restore screen reads. Must precede the migration. +# 3. prisma migrate deploy forward-only. Prisma has no down-migrations; see +# docs/DEPLOY_AND_MIGRATIONS.md — expand/contract is +# the rule, the backup is the emergency lever. +# 4. app (api + web) the new images. +# 5. verify ask the running API what it actually is. +# +# Rollback = re-dispatch with an older `tag`. That rolls back CODE only; the +# schema stays forward. This is exactly why every schema change must be +# backward-compatible with the previous release. +# +# Prereqs (once): +# - Gitea repo secrets, galactus-specific (suffix _GALACTUS so the cubex +# secrets keep working side by side): +# PORTAINER_URL_GALACTUS https://100.103.77.46:9443 +# PORTAINER_API_KEY_GALACTUS Portainer access token for galactus +# PORTAINER_ENDPOINT_ID_GALACTUS 3 +# PORTAINER_APP_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-app +# PORTAINER_DB_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-db +# PORTAINER_MINIO_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-minio +# DATABASE_URL_GALACTUS mysql://jorgecuadros:@:3306/jorgecuadros +# APP_API_ORIGIN_GALACTUS browser-facing API URL +# APP_WEB_ORIGIN_GALACTUS web public origin (API CORS) +# APP_S3_ENDPOINT_GALACTUS server-side minio URL +# SESSION_SECRET_GALACTUS 64-hex (openssl rand -hex 32) +# MINIO_ROOT_USER / MINIO_ROOT_PASSWORD +# MYSQL_PASSWORD / MYSQL_ROOT_PASSWORD +# - The runner (which lives on cubex) must be able to reach BOTH +# galactus:9443 (Portainer) and galactus:3306 (MySQL, for migrate deploy). +# If it cannot reach 3306, run the migration by hand from a host that can +# and dispatch with skip_migrate=true. +# - ONE-TIME, on a database that predates migration history (i.e. one built +# with `prisma db push`): baseline it before the first run, or step 3 fails +# with P3005 "database schema is not empty": +# npx prisma@5 migrate resolve --applied 0000_init \ +# --schema packages/database/prisma/schema.prisma + +name: Deploy to galactus + +on: + workflow_dispatch: + inputs: + tag: + description: "Image tag to deploy (1.2.3 — no leading v — or sha-, or latest)" + required: true + default: "latest" + scope: + description: "What to deploy" + type: choice + required: true + default: "app" + options: + - app + - full + bootstrap: + description: "First-ever deploy: allow the pre-migrate backup to be skipped when no API container exists yet" + type: boolean + required: false + default: false + skip_migrate: + description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)" + type: boolean + required: false + default: false + +env: + REGISTRY: git.mancinas.io + +jobs: + deploy: + name: Deploy ${{ github.event.inputs.tag }} (${{ github.event.inputs.scope }}) + runs-on: docker + container: + image: node:20-alpine + steps: + - name: Install tools + # openssl: prisma's migration engine picks its musl/openssl build at + # runtime and cannot resolve one without it. + run: apk add --no-cache openssl ca-certificates git + + - uses: actions/checkout@v4 + + # --- full only: database --------------------------------------------- + - name: Deploy database stack + if: ${{ github.event.inputs.scope == 'full' }} + uses: cssnr/portainer-stack-deploy-action@v1 + with: + url: ${{ secrets.PORTAINER_URL_GALACTUS }} + token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }} + name: ${{ secrets.PORTAINER_DB_STACK_NAME_GALACTUS }} + file: deploy/galactus/jorgecuadros-db.compose.yml + type: file + standalone: true + endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }} + env_data: | + { + "MYSQL_SERVER_ID": "1", + "MYSQL_PORT": "3306", + "MYSQL_DATABASE": "jorgecuadros", + "MYSQL_USER": "jorgecuadros", + "MYSQL_PASSWORD": "${{ secrets.MYSQL_PASSWORD }}", + "MYSQL_ROOT_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}" + } + + # --- full only: object storage --------------------------------------- + - name: Deploy minio stack + if: ${{ github.event.inputs.scope == 'full' }} + uses: cssnr/portainer-stack-deploy-action@v1 + with: + url: ${{ secrets.PORTAINER_URL_GALACTUS }} + token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }} + name: ${{ secrets.PORTAINER_MINIO_STACK_NAME_GALACTUS }} + file: deploy/galactus/jorgecuadros-minio.compose.yml + type: file + standalone: true + endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }} + env_data: | + { + "MINIO_API_PORT": "9000", + "MINIO_CONSOLE_PORT": "9001", + "MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}", + "MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}" + } + + # --- restore point, taken while the OLD api container is still up ------ + - name: Pre-migrate backup + env: + PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }} + PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }} + PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }} + DATABASE_URL: ${{ secrets.DATABASE_URL_GALACTUS }} + BACKUP_TAG: ${{ github.event.inputs.tag }} + ALLOW_MISSING_CONTAINER: ${{ github.event.inputs.bootstrap }} + # Portainer serves a self-signed certificate. Scoped to this step + # only, which does nothing but talk to Portainer. + NODE_TLS_REJECT_UNAUTHORIZED: "0" + run: node deploy/scripts/pre-migrate-backup.mjs + + # --- schema, forward-only --------------------------------------------- + - name: Apply database migrations + if: ${{ github.event.inputs.skip_migrate != 'true' }} + env: + DATABASE_URL: ${{ secrets.DATABASE_URL_GALACTUS }} + run: | + set -e + SCHEMA=packages/database/prisma/schema.prisma + npx --yes prisma@5 migrate status --schema "$SCHEMA" || true + if ! npx --yes prisma@5 migrate deploy --schema "$SCHEMA"; then + echo "::error::migrate deploy failed. If this is P3005 (schema not empty)," + echo "::error::the database predates migration history — baseline it once with:" + echo "::error:: npx prisma@5 migrate resolve --applied 0000_init --schema $SCHEMA" + exit 1 + fi + + # --- always: the app (web + api) ------------------------------------- + - name: Deploy app stack + uses: cssnr/portainer-stack-deploy-action@v1 + with: + url: ${{ secrets.PORTAINER_URL_GALACTUS }} + token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }} + name: ${{ secrets.PORTAINER_APP_STACK_NAME_GALACTUS }} + file: deploy/galactus/jorgecuadros-app.compose.yml + type: file + standalone: true + pull: true + endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }} + env_data: | + { + "APP_TAG": "${{ github.event.inputs.tag }}", + "API_PORT": "3001", + "WEB_PORT": "3000", + "S3_BUCKET": "jorgecuadros-documents", + "API_ORIGIN": "${{ secrets.APP_API_ORIGIN_GALACTUS }}", + "WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN_GALACTUS }}", + "S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT_GALACTUS }}", + "DATABASE_URL": "${{ secrets.DATABASE_URL_GALACTUS }}", + "SESSION_SECRET": "${{ secrets.SESSION_SECRET_GALACTUS }}", + "MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}", + "MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}" + } + + # --- prove it ---------------------------------------------------------- + - name: Verify running version + env: + API_ORIGIN: ${{ secrets.APP_API_ORIGIN_GALACTUS }} + WANT: ${{ github.event.inputs.tag }} + # The stack file naming a tag is not proof the container is running it — + # a skipped pull or a cached layer can leave the old code up. Ask it. + run: | + set -e + apk add --no-cache curl >/dev/null + for i in $(seq 1 30); do + if curl -fsS "$API_ORIGIN/version" > /tmp/version.json; then break; fi + echo "waiting for API ($i/30)..." + sleep 5 + done + cat /tmp/version.json + GOT=$(node -e 'console.log(require("/tmp/version.json").version)') + # Only a semver dispatch is directly comparable: metadata-action's + # {{version}} turns tag v1.2.3 into image 1.2.3, while `latest` and + # `sha-*` report the branch or short sha instead. + case "$WANT" in + [0-9]*.[0-9]*.[0-9]*) + if [ "$GOT" != "$WANT" ]; then + echo "::error::deployed $WANT but the API reports $GOT" + exit 1 + fi + echo "verified: API is running $GOT" + ;; + *) + echo "dispatched '$WANT'; API reports '$GOT' (not directly comparable)" + ;; + esac diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e56c96e..9db7bfa 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -8,6 +8,17 @@ # app = web + api only (the usual app release) [default] # full = db + minio + web + api (bring up / update the whole platform) # +# The `tag` input carries NO leading `v`: metadata-action's {{version}} turns +# git tag v1.2.3 into image tag 1.2.3. Tag v1.2.3, dispatch 1.2.3. +# +# Order: db+minio (full only) -> pre-migrate backup -> prisma migrate deploy -> +# app -> verify the API reports the version you asked for. Rollback = dispatch +# an older tag; that rolls back CODE only, never the schema, which is why every +# schema change must be expand/contract. See docs/DEPLOY_AND_MIGRATIONS.md. +# +# galactus (the office server) is standalone Docker, not this Swarm — it has its +# own workflow, .gitea/workflows/deploy-galactus.yml. +# # cssnr/portainer-stack-deploy-action creates each stack on first run and updates # it on every run, so no manual stack pre-creation in the Portainer UI. On a # `full` deploy the db + minio stacks are applied BEFORE the app (the API depends @@ -36,6 +47,14 @@ # # Database stack (full only) # MYSQL_PASSWORD app-user password (matches DATABASE_URL) # MYSQL_ROOT_PASSWORD mysql root password +# - the runner must reach BOTH Portainer (9443) and MySQL (3306) — the +# migration step connects to the database directly. If it cannot reach 3306, +# migrate by hand and dispatch with skip_migrate=true. +# - ONE-TIME on a database built with `prisma db push` (i.e. every database +# that exists today): baseline it before the first run, or the migrate step +# fails with P3005 "database schema is not empty": +# npx prisma@5 migrate resolve --applied 0000_init \ +# --schema packages/database/prisma/schema.prisma name: Deploy to Portainer @@ -54,6 +73,16 @@ on: options: - app - full + bootstrap: + description: "First-ever deploy: allow the pre-migrate backup to be skipped when no API container exists yet" + type: boolean + required: false + default: false + skip_migrate: + description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)" + type: boolean + required: false + default: false env: REGISTRY: git.mancinas.io @@ -63,8 +92,13 @@ jobs: name: Deploy (${{ github.event.inputs.scope }}) runs-on: docker container: - image: node:18-alpine + image: node:20-alpine steps: + - name: Install tools + # openssl: prisma's migration engine picks its musl/openssl build at + # runtime and cannot resolve one without it. + run: apk add --no-cache openssl ca-certificates git + - uses: actions/checkout@v4 # --- full only: database --------------------------------------------- @@ -77,7 +111,7 @@ jobs: name: ${{ secrets.PORTAINER_DB_STACK_NAME }} file: deploy/jorgecuadros-db.stack.yml type: file - endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }} + endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }} env_data: | { "MYSQL_SERVER_ID": "1", @@ -98,7 +132,7 @@ jobs: name: ${{ secrets.PORTAINER_MINIO_STACK_NAME }} file: deploy/jorgecuadros-minio.stack.yml type: file - endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }} + endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }} env_data: | { "MINIO_API_PORT": "9000", @@ -107,6 +141,44 @@ jobs: "MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}" } + # --- restore point, taken while the OLD api container is still up ------ + # Dumped INSIDE the running api container so the file lands in the volume + # the "Operaciones" restore screen reads — a dump on the runner would be + # unreachable by the only restore path this platform has. + - name: Pre-migrate backup + env: + PORTAINER_URL: ${{ secrets.PORTAINER_URL }} + PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }} + PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + BACKUP_TAG: ${{ github.event.inputs.tag }} + ALLOW_MISSING_CONTAINER: ${{ github.event.inputs.bootstrap }} + # Portainer serves a self-signed certificate. Scoped to this step + # only, which does nothing but talk to Portainer. + NODE_TLS_REJECT_UNAUTHORIZED: "0" + run: node deploy/scripts/pre-migrate-backup.mjs + + # --- schema, forward-only --------------------------------------------- + # Prisma has no down-migrations: a code rollback does NOT roll the schema + # back. See docs/DEPLOY_AND_MIGRATIONS.md — every change must be + # expand/contract so the previous release still runs against the new + # schema. Run as a deploy STEP, never as the container CMD: N replicas + # would race each other applying the same migration. + - name: Apply database migrations + if: ${{ github.event.inputs.skip_migrate != 'true' }} + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: | + set -e + SCHEMA=packages/database/prisma/schema.prisma + npx --yes prisma@5 migrate status --schema "$SCHEMA" || true + if ! npx --yes prisma@5 migrate deploy --schema "$SCHEMA"; then + echo "::error::migrate deploy failed. If this is P3005 (schema not empty)," + echo "::error::the database predates migration history — baseline it once with:" + echo "::error:: npx prisma@5 migrate resolve --applied 0000_init --schema $SCHEMA" + exit 1 + fi + # --- always: the app (web + api) ------------------------------------- - name: Deploy app stack uses: cssnr/portainer-stack-deploy-action@v1 @@ -116,8 +188,8 @@ jobs: name: ${{ secrets.PORTAINER_APP_STACK_NAME }} file: deploy/jorgecuadros-app.stack.yml type: file - pull_image: true - endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }} + pull: true + endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }} env_data: | { "APP_TAG": "${{ github.event.inputs.tag }}", @@ -132,3 +204,36 @@ jobs: "MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}", "MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}" } + + # --- prove it ---------------------------------------------------------- + # A stack naming a tag is not proof the container is running it — a + # skipped pull leaves the old code up. Ask the API what it actually is. + - name: Verify running version + env: + API_ORIGIN: ${{ secrets.APP_API_ORIGIN }} + WANT: ${{ github.event.inputs.tag }} + run: | + set -e + apk add --no-cache curl >/dev/null + for i in $(seq 1 30); do + if curl -fsS "$API_ORIGIN/version" > /tmp/version.json; then break; fi + echo "waiting for API ($i/30)..." + sleep 5 + done + cat /tmp/version.json + GOT=$(node -e 'console.log(require("/tmp/version.json").version)') + # Only a semver dispatch is directly comparable: metadata-action's + # {{version}} turns tag v1.2.3 into image 1.2.3, while `latest` and + # `sha-*` report the branch or short sha instead. + case "$WANT" in + [0-9]*.[0-9]*.[0-9]*) + if [ "$GOT" != "$WANT" ]; then + echo "::error::deployed $WANT but the API reports $GOT" + exit 1 + fi + echo "verified: API is running $GOT" + ;; + *) + echo "dispatched '$WANT'; API reports '$GOT' (not directly comparable)" + ;; + esac diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts index ff9a48c..f7858ce 100644 --- a/apps/api/src/app.controller.ts +++ b/apps/api/src/app.controller.ts @@ -6,4 +6,25 @@ export class AppController { health() { return { status: "ok" }; } + + /** + * What is actually running. The three values are baked into the image at + * build time by .gitea/workflows/build.yml (see docker/api.Dockerfile) and + * are the only way to confirm a deploy — or a rollback — landed: the tag you + * dispatched and the code inside the container can disagree if a stack was + * applied without pulling, or if the app stack still names an older tag. + * + * Deliberately unauthenticated, same as /health: the deploy workflow has to + * read it with no session, and it exposes nothing an attacker could not + * already infer from the repo. + */ + @Get("version") + version() { + return { + service: "api", + version: process.env.APP_VERSION ?? "dev", + gitSha: process.env.GIT_SHA ?? "unknown", + buildDate: process.env.BUILD_DATE ?? "unknown", + }; + } } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index be1eb62..f7a75f9 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -162,6 +162,38 @@ button { } } +/* Deployed-build line. Quiet by default — it only needs to be legible when + someone is verifying a release or a rollback. */ +.shell-footer { + max-width: var(--shell-max); + margin: 0 auto; + padding: 1rem 1.75rem 1.75rem; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.75rem; + font-size: 0.75rem; + color: var(--muted-2); + border-top: 1px solid var(--line); +} +.shell-footer-build { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + cursor: help; +} +.shell-footer-warn { + color: var(--negative); + background: var(--negative-tint); + border-radius: var(--radius-sm); + padding: 0.0625rem 0.375rem; + font-weight: 600; +} +@media (max-width: 640px) { + .shell-footer { + padding: 1rem 1rem 1.5rem; + } +} + .eyebrow { font-family: var(--font-sans); text-transform: uppercase; diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index ae43f65..4c98cd8 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import "./globals.css"; +import { readBuildInfoFromEnv } from "@/lib/build-info"; export const metadata = { title: "Jorge Cuadros & Asociados — Plataforma", @@ -20,6 +21,9 @@ export default function RootLayout({ children }: { children: ReactNode }) { process.env.API_ORIGIN ?? process.env.NEXT_PUBLIC_API_ORIGIN ?? "http://localhost:3001"; + // Same reason as the API origin: read on the server per request so the built + // image is not pinned to one build identity in its client bundle. + const build = readBuildInfoFromEnv(); return ( @@ -27,7 +31,9 @@ export default function RootLayout({ children }: { children: ReactNode }) { {/* Must run before the app bundle so lib/api.ts sees it at import. */}