Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/release-container.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/release-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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}"

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions deploy/Dockerfile.production
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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

Expand Down
4 changes: 4 additions & 0 deletions deploy/docker/frontend.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,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

Expand Down
10 changes: 8 additions & 2 deletions docs/platform/CONFIGURATION_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,17 @@ value injected):

| Build | Injection | Reported version |
| --- | --- | --- |
| Tag push / dispatch naming a tag (desktop) | `dotnet publish -p:Version=<tag minus v and +build>` 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=<tag minus v and +build>` 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)
Expand Down
10 changes: 9 additions & 1 deletion frontend/taskdeck-web/src/store/telemetryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down
7 changes: 4 additions & 3 deletions frontend/taskdeck-web/src/tests/api/telemetryApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,25 @@ 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 = [
{
event: 'capture.submitted',
timestamp: '2026-04-09T12:00:00Z',
sessionId: 'abc',
workspaceMode: 'guided',
appVersion: '0.1.0',
appVersion: injectedAppVersion,
platform: 'web' as const,
},
{
event: 'board.loaded',
timestamp: '2026-04-09T12:00:01Z',
sessionId: 'abc',
workspaceMode: 'guided',
appVersion: '0.1.0',
appVersion: injectedAppVersion,
platform: 'web' as const,
},
]
Expand Down
21 changes: 20 additions & 1 deletion frontend/taskdeck-web/src/tests/store/telemetryStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('telemetryStore', () => {
afterEach(() => {
const store = useTelemetryStore()
store.stopFlushTimer()
vi.unstubAllEnvs()
})

describe('consent', () => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion scripts/ci/release-desktop-dispatch.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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/)
Expand Down
Loading