From 38b2be61efab5aeb8f583bea599ab16f5c1b8dee Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 18 May 2026 07:09:15 +0200 Subject: [PATCH] feat(fase-14): version chip in /settings + GIT_SHA bake-through (14.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apps/web/vite.config.ts` now injects three build-time constants via `define`: `__APP_VERSION__` (root package.json), `__APP_COMMIT__` (GIT_SHA env > local `git rev-parse` > 'dev'), `__BUILD_TIME__` (ISO timestamp at build). They're declared in `apps/web/src/global.d.ts` and re-exported through `$lib/version` — components consume the named exports rather than the magic globals so an accidental tree-shake would break the test, not the page silently. `/settings` gains an "About" section at the bottom: a small chip `v{version} · {commit} · {date}` in muted text. Not interactive, purely diagnostic — useful when a user reports a bug from a stale PWA install. Deploy pipeline: - `apps/web/Dockerfile` accepts a `GIT_SHA` build arg and exports it as an env var for the SvelteKit build step. - `infra/docker-compose.erosi.yml` forwards `${GIT_SHA:-dev}` into the build args. - `infra/scripts/deploy-erosi.sh` captures `git rev-parse --short HEAD` locally (the deploy host has .git; the remote doesn't — rsync excludes it) and forwards it via `ssh ... env GIT_SHA=…` so the remote `docker compose build` sees it. V-01/V-02 vitest: stub the three globals via `vi.stubGlobal` (vitest doesn't run through the main vite.config so `define` isn't applied), assert the named exports thread through and never collapse to undefined or the 'dev' sentinel under a real build. Co-Authored-By: Claude Opus 4.7 --- apps/web/Dockerfile | 6 +++ apps/web/src/lib/version.test.ts | 46 +++++++++++++++++++ apps/web/src/lib/version.ts | 19 ++++++++ .../src/routes/(app)/settings/+page.svelte | 12 +++++ apps/web/vite.config.ts | 29 ++++++++++++ infra/docker-compose.erosi.yml | 4 ++ infra/scripts/deploy-erosi.sh | 22 +++++++-- 7 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/lib/version.test.ts create mode 100644 apps/web/src/lib/version.ts diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index a1ee512..74b127e 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -22,6 +22,11 @@ ARG PUBLIC_KEYCLOAK_URL ARG PUBLIC_KEYCLOAK_REALM ARG PUBLIC_KEYCLOAK_CLIENT_ID ARG PUBLIC_APP_URL +# Fase 14.3.5 — short git sha of the deploy. Surfaced in /settings via +# `$lib/version`. Optional: when unset the build falls back to `git +# rev-parse` (only works if .git is in the build context) and then to +# the literal 'dev'. +ARG GIT_SHA ENV PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL ENV PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY @@ -29,6 +34,7 @@ ENV PUBLIC_KEYCLOAK_URL=$PUBLIC_KEYCLOAK_URL ENV PUBLIC_KEYCLOAK_REALM=$PUBLIC_KEYCLOAK_REALM ENV PUBLIC_KEYCLOAK_CLIENT_ID=$PUBLIC_KEYCLOAK_CLIENT_ID ENV PUBLIC_APP_URL=$PUBLIC_APP_URL +ENV GIT_SHA=$GIT_SHA COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules diff --git a/apps/web/src/lib/version.test.ts b/apps/web/src/lib/version.test.ts new file mode 100644 index 0000000..3bac6a5 --- /dev/null +++ b/apps/web/src/lib/version.test.ts @@ -0,0 +1,46 @@ +/** + * Fase 14.3.6 — guard the version.ts barrel against accidental + * tree-shaking / undefined replacement. + * + * Vitest doesn't run through Vite's `define`, so the `__APP_*` + * globals don't exist in this environment. We stub them via + * `vi.stubGlobal` to mimic what `vite build` (and `vite dev`) + * inject at build/dev time. + * + * The point of the test is two-fold: + * 1. The module re-exports the three globals as an object so + * consumers never reach for the `__APP_*` symbols inline. + * 2. None of the three exports collapse to `undefined` or the + * sentinel `'dev'` when the build-time substitution is + * working — that's the canary for an accidental tree-shake + * that would silently break the version chip in production. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('version barrel', () => { + beforeEach(() => { + vi.resetModules(); + vi.stubGlobal('__APP_VERSION__', '1.2.3'); + vi.stubGlobal('__APP_COMMIT__', 'abc1234'); + vi.stubGlobal('__BUILD_TIME__', '2026-05-18T10:00:00.000Z'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('V-01: exports version / commit / builtAt from the build-time define', async () => { + const mod = await import('./version'); + expect(mod.version).toBe('1.2.3'); + expect(mod.commit).toBe('abc1234'); + expect(mod.builtAt).toBe('2026-05-18T10:00:00.000Z'); + }); + + it('V-02: none of the exports are undefined or the "dev" sentinel under a real build define', async () => { + const mod = await import('./version'); + for (const v of [mod.version, mod.commit, mod.builtAt]) { + expect(v).toBeTruthy(); + expect(v).not.toBe('dev'); + } + }); +}); diff --git a/apps/web/src/lib/version.ts b/apps/web/src/lib/version.ts new file mode 100644 index 0000000..3996a94 --- /dev/null +++ b/apps/web/src/lib/version.ts @@ -0,0 +1,19 @@ +/** + * Fase 14.3 — build-time version information. + * + * The `__APP_*` globals are inlined by Vite's `define` (see + * `apps/web/vite.config.ts`) at build AND dev time. Touching them + * only through this barrel keeps the substitution localised: + * + * - If the symbol is ever tree-shaken out of a component, only + * this module breaks (caught by `version.test.ts`). + * - Consumers depend on a stable named export instead of a magic + * global, so swapping the source (e.g. moving to a runtime + * /version endpoint) is a one-file change. + * + * Declarations for the three globals live in + * `apps/web/src/global.d.ts`. + */ +export const version: string = __APP_VERSION__; +export const commit: string = __APP_COMMIT__; +export const builtAt: string = __BUILD_TIME__; diff --git a/apps/web/src/routes/(app)/settings/+page.svelte b/apps/web/src/routes/(app)/settings/+page.svelte index 44b7c62..edb5394 100644 --- a/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/web/src/routes/(app)/settings/+page.svelte @@ -30,6 +30,7 @@ PUBLIC_KEYCLOAK_URL, PUBLIC_KEYCLOAK_REALM } from '$env/static/public'; + import { version, commit, builtAt } from '$lib/version'; // Profile state loaded from public.users let displayNameInput = $state(''); @@ -607,6 +608,17 @@ + +
+

+ {m.settings_about()} +

+

+ {m.settings_about_version({ version, commit, date: builtAt.slice(0, 10) })} +

+
+

diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a214446..8f008d2 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,8 +2,37 @@ import { defineConfig } from 'vite'; import { sveltekit } from '@sveltejs/kit/vite'; import { SvelteKitPWA } from '@vite-pwa/sveltekit'; import { paraglide } from '@inlang/paraglide-sveltekit/vite'; +import { execSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +// Fase 14.3.1 — build-time version constants. +// Source order: explicit GIT_SHA env (used by `apps/web/Dockerfile` +// during `infra/scripts/deploy-erosi.sh`) > local `git rev-parse` > +// 'dev' fallback when neither path works (e.g. tarball builds). +const rootPkgUrl = new URL('../../package.json', import.meta.url); +const rootPkg = JSON.parse(readFileSync(fileURLToPath(rootPkgUrl), 'utf8')) as { + version?: string; +}; +const APP_VERSION = rootPkg.version ?? '0.0.0'; +let APP_COMMIT: string = process.env.GIT_SHA ?? ''; +if (!APP_COMMIT) { + try { + APP_COMMIT = execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString() + .trim(); + } catch { + APP_COMMIT = 'dev'; + } +} +const BUILD_TIME = new Date().toISOString(); export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(APP_VERSION), + __APP_COMMIT__: JSON.stringify(APP_COMMIT), + __BUILD_TIME__: JSON.stringify(BUILD_TIME) + }, plugins: [ paraglide({ project: './project.inlang', diff --git a/infra/docker-compose.erosi.yml b/infra/docker-compose.erosi.yml index e60c301..7205044 100644 --- a/infra/docker-compose.erosi.yml +++ b/infra/docker-compose.erosi.yml @@ -178,6 +178,10 @@ services: PUBLIC_KEYCLOAK_REALM: ${PUBLIC_KEYCLOAK_REALM} PUBLIC_KEYCLOAK_CLIENT_ID: ${PUBLIC_KEYCLOAK_CLIENT_ID} PUBLIC_APP_URL: ${PUBLIC_APP_URL} + # Fase 14.3.5 — short git sha of the host's checkout, exported + # by infra/scripts/deploy-erosi.sh before invoking the build. + # Defaults to literal 'dev' if the deployer didn't set GIT_SHA. + GIT_SHA: ${GIT_SHA:-dev} restart: always environment: PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL} diff --git a/infra/scripts/deploy-erosi.sh b/infra/scripts/deploy-erosi.sh index 9bce207..ae88da9 100755 --- a/infra/scripts/deploy-erosi.sh +++ b/infra/scripts/deploy-erosi.sh @@ -26,7 +26,13 @@ DEPLOY_HOST="${DEPLOY_HOST:-ambrosio}" DEPLOY_PATH="${DEPLOY_PATH:-/opt/colectivo}" REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -echo "==> Deploying to $DEPLOY_HOST:$DEPLOY_PATH" +# Fase 14.3.5 — capture the short git sha on the deploy host (this +# machine) so it can be baked into the built app bundle as +# `__APP_COMMIT__`. We do it here rather than on ambrosio because we +# rsync without `.git`, so the remote can't run `git rev-parse`. +GIT_SHA="$(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo dev)" + +echo "==> Deploying to $DEPLOY_HOST:$DEPLOY_PATH (commit $GIT_SHA)" # ── 1. Ensure target dir exists ──────────────────────────────────────────── ssh "$DEPLOY_HOST" "sudo mkdir -p $DEPLOY_PATH && sudo chown \$(id -u):\$(id -g) $DEPLOY_PATH" @@ -104,15 +110,21 @@ fi REMOTE # ── 4. Build + bring up the stack ───────────────────────────────────────── -ssh "$DEPLOY_HOST" bash -s << 'REMOTE' +# We forward GIT_SHA over the ssh env so the remote `docker compose build` +# can substitute it into the Dockerfile build-arg (compose reads it from +# the same shell, not from .env, so a transient export is enough). ssh +# doesn't accept VAR=value args directly, so we prefix `env`. +ssh "$DEPLOY_HOST" env GIT_SHA="$GIT_SHA" bash -s << 'REMOTE' set -euo pipefail cd /opt/colectivo +: "${GIT_SHA:=dev}" -echo "--- Building app image" -docker compose --env-file .env -f infra/docker-compose.erosi.yml build app +echo "--- Building app image (commit $GIT_SHA)" +GIT_SHA="$GIT_SHA" docker compose --env-file .env -f infra/docker-compose.erosi.yml \ + build --build-arg GIT_SHA="$GIT_SHA" app echo "--- Bringing stack up (detached)" -docker compose --env-file .env -f infra/docker-compose.erosi.yml up -d +GIT_SHA="$GIT_SHA" docker compose --env-file .env -f infra/docker-compose.erosi.yml up -d echo "--- Waiting for db to be healthy" for i in $(seq 1 60); do