feat(fase-14): version chip in /settings + GIT_SHA bake-through (14.3)
`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 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,11 @@ ARG PUBLIC_KEYCLOAK_URL
|
|||||||
ARG PUBLIC_KEYCLOAK_REALM
|
ARG PUBLIC_KEYCLOAK_REALM
|
||||||
ARG PUBLIC_KEYCLOAK_CLIENT_ID
|
ARG PUBLIC_KEYCLOAK_CLIENT_ID
|
||||||
ARG PUBLIC_APP_URL
|
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_URL=$PUBLIC_SUPABASE_URL
|
||||||
ENV PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY
|
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_REALM=$PUBLIC_KEYCLOAK_REALM
|
||||||
ENV PUBLIC_KEYCLOAK_CLIENT_ID=$PUBLIC_KEYCLOAK_CLIENT_ID
|
ENV PUBLIC_KEYCLOAK_CLIENT_ID=$PUBLIC_KEYCLOAK_CLIENT_ID
|
||||||
ENV PUBLIC_APP_URL=$PUBLIC_APP_URL
|
ENV PUBLIC_APP_URL=$PUBLIC_APP_URL
|
||||||
|
ENV GIT_SHA=$GIT_SHA
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
||||||
|
|||||||
46
apps/web/src/lib/version.test.ts
Normal file
46
apps/web/src/lib/version.test.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
19
apps/web/src/lib/version.ts
Normal file
19
apps/web/src/lib/version.ts
Normal file
@@ -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__;
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
PUBLIC_KEYCLOAK_URL,
|
PUBLIC_KEYCLOAK_URL,
|
||||||
PUBLIC_KEYCLOAK_REALM
|
PUBLIC_KEYCLOAK_REALM
|
||||||
} from '$env/static/public';
|
} from '$env/static/public';
|
||||||
|
import { version, commit, builtAt } from '$lib/version';
|
||||||
|
|
||||||
// Profile state loaded from public.users
|
// Profile state loaded from public.users
|
||||||
let displayNameInput = $state('');
|
let displayNameInput = $state('');
|
||||||
@@ -607,6 +608,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- About (Fase 14.3.4) — version / commit / build-time chip.
|
||||||
|
Not clickable, just informational. -->
|
||||||
|
<section class="pt-8" data-testid="settings-about">
|
||||||
|
<p class="mb-2 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||||
|
{m.settings_about()}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-text-secondary" data-testid="settings-about-version">
|
||||||
|
{m.settings_about_version({ version, commit, date: builtAt.slice(0, 10) })}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Danger zone (Fase 10.5) -->
|
<!-- Danger zone (Fase 10.5) -->
|
||||||
<section class="pt-8">
|
<section class="pt-8">
|
||||||
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-destructive">
|
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-destructive">
|
||||||
|
|||||||
@@ -2,8 +2,37 @@ import { defineConfig } from 'vite';
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
|
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
|
||||||
import { paraglide } from '@inlang/paraglide-sveltekit/vite';
|
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({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(APP_VERSION),
|
||||||
|
__APP_COMMIT__: JSON.stringify(APP_COMMIT),
|
||||||
|
__BUILD_TIME__: JSON.stringify(BUILD_TIME)
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
paraglide({
|
paraglide({
|
||||||
project: './project.inlang',
|
project: './project.inlang',
|
||||||
|
|||||||
@@ -178,6 +178,10 @@ services:
|
|||||||
PUBLIC_KEYCLOAK_REALM: ${PUBLIC_KEYCLOAK_REALM}
|
PUBLIC_KEYCLOAK_REALM: ${PUBLIC_KEYCLOAK_REALM}
|
||||||
PUBLIC_KEYCLOAK_CLIENT_ID: ${PUBLIC_KEYCLOAK_CLIENT_ID}
|
PUBLIC_KEYCLOAK_CLIENT_ID: ${PUBLIC_KEYCLOAK_CLIENT_ID}
|
||||||
PUBLIC_APP_URL: ${PUBLIC_APP_URL}
|
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
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL}
|
PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL}
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ DEPLOY_HOST="${DEPLOY_HOST:-ambrosio}"
|
|||||||
DEPLOY_PATH="${DEPLOY_PATH:-/opt/colectivo}"
|
DEPLOY_PATH="${DEPLOY_PATH:-/opt/colectivo}"
|
||||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
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 ────────────────────────────────────────────
|
# ── 1. Ensure target dir exists ────────────────────────────────────────────
|
||||||
ssh "$DEPLOY_HOST" "sudo mkdir -p $DEPLOY_PATH && sudo chown \$(id -u):\$(id -g) $DEPLOY_PATH"
|
ssh "$DEPLOY_HOST" "sudo mkdir -p $DEPLOY_PATH && sudo chown \$(id -u):\$(id -g) $DEPLOY_PATH"
|
||||||
@@ -104,15 +110,21 @@ fi
|
|||||||
REMOTE
|
REMOTE
|
||||||
|
|
||||||
# ── 4. Build + bring up the stack ─────────────────────────────────────────
|
# ── 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
|
set -euo pipefail
|
||||||
cd /opt/colectivo
|
cd /opt/colectivo
|
||||||
|
: "${GIT_SHA:=dev}"
|
||||||
|
|
||||||
echo "--- Building app image"
|
echo "--- Building app image (commit $GIT_SHA)"
|
||||||
docker compose --env-file .env -f infra/docker-compose.erosi.yml build app
|
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)"
|
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"
|
echo "--- Waiting for db to be healthy"
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
|
|||||||
Reference in New Issue
Block a user