From 009581f04c4f3903b009cae4fb8aec2a79ea7c67 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 4 Sep 2026 07:56:27 +0100 Subject: [PATCH] fix(telemetry): source frontend app version from build config --- .github/workflows/release-container.yml | 1 + .github/workflows/release-desktop.yml | 14 +++++++++++++ deploy/Dockerfile.production | 4 ++++ deploy/docker/frontend.Dockerfile | 4 ++++ docs/platform/CONFIGURATION_REFERENCE.md | 10 +++++++-- .../taskdeck-web/src/store/telemetryStore.ts | 10 ++++++++- .../src/tests/api/telemetryApi.spec.ts | 7 ++++--- .../src/tests/store/telemetryStore.spec.ts | 21 ++++++++++++++++++- scripts/ci/release-desktop-dispatch.test.mjs | 12 ++++++++++- 9 files changed, 75 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-container.yml b/.github/workflows/release-container.yml index 5ba05c2de..fb74d8489 100644 --- a/.github/workflows/release-container.yml +++ b/.github/workflows/release-container.yml @@ -120,6 +120,7 @@ jobs: push: ${{ startsWith(github.ref, 'refs/tags/v') }} build-args: | TASKDECK_VERSION=${{ steps.product-version.outputs.version }} + VITE_APP_VERSION=${{ steps.product-version.outputs.version }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} no-cache: true diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 29c3842a8..35e2256a0 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -79,6 +79,7 @@ jobs: sha: ${{ steps.resolve.outputs.sha }} publish: ${{ steps.resolve.outputs.publish }} prerelease: ${{ steps.resolve.outputs.prerelease }} + version: ${{ steps.resolve.outputs.version }} steps: # Only the tag validator is needed here; nothing in this job writes to Git. - name: Checkout @@ -164,6 +165,16 @@ jobs: # Re-validate the final tag, which also covers the generated rehearsal name. tag="$(bash scripts/ci/validate-release-tag.sh "${tag}")" + # The frontend and backend must receive the same normalized version. Keep + # this derived from the validated final tag so no frontend source needs a + # hand-maintained release number. + version="${tag#v}" + version="${version%%+*}" + if ! printf '%s' "${version}" | grep -Eq '^[0-9A-Za-z][0-9A-Za-z.-]*$'; then + printf '::error::Refusing product version %q — not a plain version token.\n' "${version}" + exit 1 + fi + # --- 4. Decide the publish semantics from that same tag ------------ # Read off the VALIDATED tag string, so the release's label and the # bytes published under it can never disagree about which tag they @@ -181,6 +192,7 @@ jobs: printf 'sha=%s\n' "${sha}" printf 'publish=%s\n' "${publish}" printf 'prerelease=%s\n' "${prerelease}" + printf 'version=%s\n' "${version}" } >> "${GITHUB_OUTPUT}" # The Markdown code-span delimiter is held in a variable and passed as a @@ -195,6 +207,7 @@ jobs: printf '| commit | %s%s%s |\n' "${bt}" "${sha}" "${bt}" printf '| publishes a release | %s%s%s |\n' "${bt}" "${publish}" "${bt}" printf '| published as a prerelease | %s%s%s |\n' "${bt}" "${prerelease}" "${bt}" + printf '| normalized version | %s%s%s |\n' "${bt}" "${version}" "${bt}" } >> "${GITHUB_STEP_SUMMARY}" # --------------------------------------------------------------------------- @@ -241,6 +254,7 @@ jobs: working-directory: frontend/taskdeck-web env: VITE_API_BASE_URL: /api + VITE_APP_VERSION: ${{ needs.resolve-source.outputs.version }} run: npx vite build - name: Upload frontend dist diff --git a/deploy/Dockerfile.production b/deploy/Dockerfile.production index 09c15b8b7..357fc01e2 100644 --- a/deploy/Dockerfile.production +++ b/deploy/Dockerfile.production @@ -37,6 +37,10 @@ COPY frontend/taskdeck-web/ ./ # same origin (no /api prefix rewrite needed — the API serves at /api/*). ARG VITE_API_BASE_URL=/api ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +# Keep frontend telemetry on the same release stamp as the backend. Release +# workflows override this; local and rehearsal builds keep the development fallback. +ARG VITE_APP_VERSION=0.0.0-dev +ENV VITE_APP_VERSION=$VITE_APP_VERSION RUN npm run build diff --git a/deploy/docker/frontend.Dockerfile b/deploy/docker/frontend.Dockerfile index c3a980e13..279b0f964 100644 --- a/deploy/docker/frontend.Dockerfile +++ b/deploy/docker/frontend.Dockerfile @@ -8,6 +8,10 @@ COPY frontend/taskdeck-web/ ./ ARG VITE_API_BASE_URL=/api ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +# Release builds may override this to keep frontend telemetry aligned with the +# backend; standalone and local builds retain the development fallback. +ARG VITE_APP_VERSION=0.0.0-dev +ENV VITE_APP_VERSION=$VITE_APP_VERSION RUN npm run build diff --git a/docs/platform/CONFIGURATION_REFERENCE.md b/docs/platform/CONFIGURATION_REFERENCE.md index d3b040d75..5c444ea9b 100644 --- a/docs/platform/CONFIGURATION_REFERENCE.md +++ b/docs/platform/CONFIGURATION_REFERENCE.md @@ -100,11 +100,17 @@ value injected): | Build | Injection | Reported version | | --- | --- | --- | -| Tag push / dispatch naming a tag (desktop) | `dotnet publish -p:Version=` in `.github/workflows/release-desktop.yml`, taken from the `resolve-source` job's validated tag | e.g. `0.1.0` | -| Tag push (container) | `TASKDECK_VERSION` build arg → `/p:Version=` in `deploy/Dockerfile.production`, passed by `.github/workflows/release-container.yml` | e.g. `0.1.0` | +| Tag push / dispatch naming a tag (desktop) | `VITE_APP_VERSION=` for the frontend plus `dotnet publish -p:Version=...` for the backend, both derived from `.github/workflows/release-desktop.yml`'s validated `resolve-source` output | e.g. `0.1.0` | +| Tag push (container) | `VITE_APP_VERSION` and `TASKDECK_VERSION` build args → frontend build and `/p:Version=` in `deploy/Dockerfile.production`, passed by `.github/workflows/release-container.yml` | e.g. `0.1.0` | | Rehearsal dispatch of Release Desktop | `resolve-source`'s generated dry-run tag | `0.0.0-dryrun` | | Local build, `docker build`, CI, rehearsal container build | none | `0.0.0-dev` | +Frontend telemetry reads `VITE_APP_VERSION` at Vite build time. Release Desktop and +Release Container pass the same normalized tag-derived value used for the backend; +the standalone frontend Dockerfile accepts the same build arg. Builds without that +value use `0.0.0-dev`, so no frontend source file contains a hand-maintained release +version. + Where to read it: - `GET /health/live` → `version` (anonymous, cheapest probe) diff --git a/frontend/taskdeck-web/src/store/telemetryStore.ts b/frontend/taskdeck-web/src/store/telemetryStore.ts index 69540b495..63cb0d59f 100644 --- a/frontend/taskdeck-web/src/store/telemetryStore.ts +++ b/frontend/taskdeck-web/src/store/telemetryStore.ts @@ -9,10 +9,18 @@ import type { const CONSENT_KEY = 'taskdeck_telemetry_consent' const FLUSH_INTERVAL_MS = 30_000 // 30 seconds const MAX_BUFFER_SIZE = 200 +const DEFAULT_APP_VERSION = '0.0.0-dev' type PrivacyAwareNavigator = Navigator & { globalPrivacyControl?: boolean } +function buildAppVersion(): string { + const configured = import.meta.env.VITE_APP_VERSION + return typeof configured === 'string' && configured.trim().length > 0 + ? configured.trim() + : DEFAULT_APP_VERSION +} + /** * Checks whether the browser signals Do Not Track (DNT) or * Global Privacy Control (GPC). When either is active, telemetry @@ -155,7 +163,7 @@ export const useTelemetryStore = defineStore('telemetry', () => { timestamp: new Date().toISOString(), sessionId: sessionId.value, workspaceMode: 'guided', // Will be overridden by caller when available - appVersion: '0.1.0', // Will be set from build config in future + appVersion: buildAppVersion(), platform: 'web', properties, } diff --git a/frontend/taskdeck-web/src/tests/api/telemetryApi.spec.ts b/frontend/taskdeck-web/src/tests/api/telemetryApi.spec.ts index 4a7ce96d0..e184e4553 100644 --- a/frontend/taskdeck-web/src/tests/api/telemetryApi.spec.ts +++ b/frontend/taskdeck-web/src/tests/api/telemetryApi.spec.ts @@ -31,8 +31,9 @@ describe('telemetryApi', () => { }) describe('sendEvents', () => { - it('should post events to correct endpoint', async () => { + it('should preserve the build-injected app version when posting events', async () => { vi.mocked(http.post).mockResolvedValue({ data: { recorded: 2 } }) + const injectedAppVersion = '0.2.0' const events = [ { @@ -40,7 +41,7 @@ describe('telemetryApi', () => { timestamp: '2026-04-09T12:00:00Z', sessionId: 'abc', workspaceMode: 'guided', - appVersion: '0.1.0', + appVersion: injectedAppVersion, platform: 'web' as const, }, { @@ -48,7 +49,7 @@ describe('telemetryApi', () => { timestamp: '2026-04-09T12:00:01Z', sessionId: 'abc', workspaceMode: 'guided', - appVersion: '0.1.0', + appVersion: injectedAppVersion, platform: 'web' as const, }, ] diff --git a/frontend/taskdeck-web/src/tests/store/telemetryStore.spec.ts b/frontend/taskdeck-web/src/tests/store/telemetryStore.spec.ts index b319ee9e2..a8d5d9117 100644 --- a/frontend/taskdeck-web/src/tests/store/telemetryStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/telemetryStore.spec.ts @@ -26,6 +26,7 @@ describe('telemetryStore', () => { afterEach(() => { const store = useTelemetryStore() store.stopFlushTimer() + vi.unstubAllEnvs() }) describe('consent', () => { @@ -57,7 +58,7 @@ describe('telemetryStore', () => { timestamp: new Date().toISOString(), sessionId: 'abc', workspaceMode: 'guided', - appVersion: '0.1.0', + appVersion: '0.0.0-dev', platform: 'web', }) expect(store.eventBuffer.length).toBe(1) @@ -117,6 +118,24 @@ describe('telemetryStore', () => { expect(store.eventBuffer[0].properties).toEqual({ source: 'manual' }) }) + it('uses the injected build version and falls back for an empty build value', () => { + vi.stubEnv('VITE_APP_VERSION', ' 0.2.0 ') + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { enabled: false, provider: '', scriptUrl: '', siteId: '' }, + telemetry: { enabled: true }, + } + + store.emit('release.versioned') + expect(store.eventBuffer[0]?.appVersion).toBe('0.2.0') + + vi.stubEnv('VITE_APP_VERSION', ' ') + store.emit('release.fallback') + expect(store.eventBuffer[1]?.appVersion).toBe('0.0.0-dev') + }) + it('should cap buffer size to prevent unbounded growth', () => { const store = useTelemetryStore() store.setConsent(true) diff --git a/scripts/ci/release-desktop-dispatch.test.mjs b/scripts/ci/release-desktop-dispatch.test.mjs index 421f84ebd..afcd1d53f 100644 --- a/scripts/ci/release-desktop-dispatch.test.mjs +++ b/scripts/ci/release-desktop-dispatch.test.mjs @@ -222,6 +222,14 @@ test('resolve-source publishes the tag, commit and publish decision as job outpu assert.match(job, /tag: \$\{\{ steps\.resolve\.outputs\.tag \}\}/) assert.match(job, /sha: \$\{\{ steps\.resolve\.outputs\.sha \}\}/) assert.match(job, /publish: \$\{\{ steps\.resolve\.outputs\.publish \}\}/) + assert.match(job, /version: \$\{\{ steps\.resolve\.outputs\.version \}\}/) +}) + +test('resolve-source derives the frontend version from the validated tag', () => { + const job = jobBlock('resolve-source') + assert.match(job, /version="\$\{tag#v\}"/) + assert.match(job, /version="\$\{version%%\+\*\}"/) + assert.match(job, /printf 'version=%s\\n' "\$\{version\}"/) }) test('resolve-source dereferences annotated tags and refuses anything else', () => { @@ -310,7 +318,9 @@ test('the 0.1.x release matrix and packaging are Windows x64 zip only', () => { test('the desktop package marker is publish-only and the false pre-ZIP proof stays removed', () => { const job = jobBlock('build-backend') assert.match(job, /-p:TaskdeckDesktopPackage=true/) - assert.match(jobBlock('build-frontend'), /VITE_API_BASE_URL: \/api/) + const frontendJob = jobBlock('build-frontend') + assert.match(frontendJob, /VITE_API_BASE_URL: \/api/) + assert.match(frontendJob, /VITE_APP_VERSION: \$\{\{ needs\.resolve-source\.outputs\.version \}\}/) assert.doesNotMatch(job, /Smoke test published executable/) assert.doesNotMatch(job, /ConnectionStrings__DefaultConnection/) assert.doesNotMatch(job, /Jwt__SecretKey/)