diff --git a/CHANGELOG.md b/CHANGELOG.md index 51da0287c6..9bdcc4269d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,19 @@ aggregate instead: an italic *Catalog* line at the end of the version section an ### Fixed +- **The frontend deploy was broken by the version fix that preceded it** — #10485 read the version + from the repo-root `pyproject.toml` at build time, but the frontend image is built with + `docker build -f app/Dockerfile app`: the build context is `app/` alone, so nothing above it + exists and `vite build` died with `ENOENT .../pyproject.toml`. Cloud Build failed, Cloud Run kept + serving the previous revision, and the CSP fix never reached anyplot.ai even though every PR + check was green — the exact failure mode `CLAUDE.md` warns about. The version now comes from + `app/package.json`, which is always inside the build context, and + `tests/unit/test_version_sync.py` holds it equal to the `pyproject.toml` `[project]` version. + That test runs on every PR touching `pyproject.toml`, so release PRs cannot reintroduce the + drift that left `app/package.json` at 2.0.0 through the whole 3.x line (#10486). +- **The client bundle no longer ships the frontend dependency list** — `global-config.ts` imported + `package.json` as a default import, which inlined the entire manifest (every dependency name and + version range) into a shipped chunk. It now imports only the `version` named export (#10486). - **The masthead had been advertising v1.0 since the security-headers rollout** — the version next to `~/anyplot.ai` comes from the GitHub releases API, and the CSP shipped in the 2026-07-16 audit never listed `api.github.com` under `connect-src`. Every browser blocked that request, the hook diff --git a/agentic/commands/release.md b/agentic/commands/release.md index dec3847848..49acf8ddf8 100644 --- a/agentic/commands/release.md +++ b/agentic/commands/release.md @@ -38,7 +38,9 @@ version: $1 (optional — e.g. `3.1.0`; if omitted, propose one from the `[Unrel `[Unreleased]` compare link to `vX.Y.Z...HEAD` and add the new `[X.Y.Z]` compare link — one link per bracketed heading, this step is easy to forget. 4. **Bump the version** in `pyproject.toml` to `X.Y.Z`, then run `uv lock` so `uv.lock` picks up - the project's own version. + the project's own version. Bump `app/package.json` to the same `X.Y.Z` — the masthead falls + back to it whenever the GitHub releases lookup is unavailable, and + `tests/unit/test_version_sync.py` fails the PR if the two drift. 5. **Open the release PR** (`release: vX.Y.Z` title) and follow the standard PR follow-through from `CLAUDE.md`. Ask the user to merge unless explicitly authorized to merge autonomously. 6. **Tag after merge** (on the updated `main`): diff --git a/app/package.json b/app/package.json index 95521e067b..e58c51becf 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "anyplot-website", - "version": "2.0.0", + "version": "3.1.0", "description": "anyplot Frontend - AI-powered plotting examples", "private": true, "type": "module", diff --git a/app/project-version.ts b/app/project-version.ts deleted file mode 100644 index 0074b5b4b4..0000000000 --- a/app/project-version.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; - -/** - * Read the project version from the repo-root `pyproject.toml`. - * - * `pyproject.toml` is the single source of truth for the release version: the - * release flow (`agentic/commands/release.md`) bumps it, tags `vX.Y.Z`, and - * publishes the matching GitHub release. Vite injects the value as - * `__APP_VERSION__` so the frontend has an honest version at build time — with - * no second version field anyone has to remember to bump. - * - * Used by `vite.config.ts` and `vitest.config.ts`; runs in Node at config time, - * never in the browser bundle. - */ -export function readProjectVersion(): string { - const path = fileURLToPath(new URL('../pyproject.toml', import.meta.url)); - // Line-anchored so `python_version = "3.13"` under [tool.mypy] can't match; - // the `[project]` table's own key is the only one at column 0. - const match = /^version\s*=\s*"([^"]+)"/m.exec(readFileSync(path, 'utf8')); - if (!match) throw new Error(`No [project] version found in ${path}`); - return match[1]; -} diff --git a/app/src/global-config.test.ts b/app/src/global-config.test.ts index ee8ad1fd5d..ab582d24fe 100644 --- a/app/src/global-config.test.ts +++ b/app/src/global-config.test.ts @@ -3,19 +3,18 @@ import { describe, expect, it } from 'vitest'; import { CONFIG } from 'src/global-config'; describe('CONFIG.appVersion', () => { - // Guards the `__APP_VERSION__` injection wired up in vite.config.ts and - // vitest.config.ts. Drop the define from either one and this fails loudly - // rather than silently shipping an undefined version — which is what the - // masthead falls back to whenever the GitHub releases API is blocked or - // rate-limits the visitor. + // The masthead renders this as its version whenever the GitHub releases + // lookup has not answered or is unavailable, so an empty or placeholder value + // is visible to visitors. // - // The value's provenance is guarded at build time instead of here: - // readProjectVersion() (app/project-version.ts) throws when it cannot find - // the [project] version in pyproject.toml, and the frontend has no second - // version field left to drift from. Re-reading the file in this suite is not - // an option either — the frontend does not depend on @types/node, and - // `yarn type-check` type-checks the tests via tsconfig.test.json. - it('is injected from pyproject.toml as a semver triple', () => { - expect(CONFIG.appVersion).toMatch(/^\d+\.\d+\.\d+/); + // It deliberately comes from app/package.json rather than the repo-root + // pyproject.toml: the frontend image is built with + // `docker build -f app/Dockerfile app`, so nothing above app/ exists at build + // time. tests/unit/test_version_sync.py holds the two fields equal. + // Anchored at both ends, matching tests/unit/test_version_sync.py: releases are + // plain X.Y.Z triples, so `3.1.0-beta` or `3.1.0.1` reaching the masthead is a + // bug, not a variant to tolerate. + it('is a semver triple, not a placeholder', () => { + expect(CONFIG.appVersion).toMatch(/^\d+\.\d+\.\d+$/); }); }); diff --git a/app/src/global-config.ts b/app/src/global-config.ts index 41fd74205e..465c559169 100644 --- a/app/src/global-config.ts +++ b/app/src/global-config.ts @@ -1,3 +1,8 @@ +// Named import, not a default one: `import packageJson from '../package.json'` +// inlines the whole manifest — the full dependency list included — into the +// client bundle. Only `version` is needed here. +import { version as packageVersion } from '../package.json'; + interface GlobalConfig { appName: string; appVersion: string; @@ -12,10 +17,13 @@ const apiBaseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'; export const CONFIG: GlobalConfig = { appName: 'anyplot', - // Build-time version from the repo-root pyproject.toml — the same file the - // release flow bumps. app/package.json is private npm metadata and drifts - // (it sat at 2.0.0 through the 3.x releases), so it is not the source here. - appVersion: __APP_VERSION__, + // Read from app/package.json, NOT the repo-root pyproject.toml: the frontend + // image is built with `docker build -f app/Dockerfile app`, so the build + // context is app/ alone and anything above it does not exist at build time + // (reading ../pyproject.toml here broke the Cloud Build deploy in #10485). + // The two version fields are kept identical by tests/unit/test_version_sync.py, + // which runs on every PR that touches pyproject.toml — including release PRs. + appVersion: packageVersion, api: { baseUrl: apiBaseUrl, // DebugPage uses this — set to "/api" in prod (same-origin via the diff --git a/app/src/vite-env.d.ts b/app/src/vite-env.d.ts index 5939eb94c4..9e1e1857fb 100644 --- a/app/src/vite-env.d.ts +++ b/app/src/vite-env.d.ts @@ -9,10 +9,6 @@ interface ImportMeta { readonly env: ImportMetaEnv; } -// Injected by vite.config.ts / vitest.config.ts from the [project] version in -// the repo-root pyproject.toml (see app/project-version.ts). -declare const __APP_VERSION__: string; - // Deep ESM imports not covered by @types/react-syntax-highlighter declare module 'react-syntax-highlighter/dist/esm/prism-light'; declare module 'react-syntax-highlighter/dist/esm/styles/prism'; diff --git a/app/tsconfig.node.json b/app/tsconfig.node.json index 555e1a326b..42872c59f5 100644 --- a/app/tsconfig.node.json +++ b/app/tsconfig.node.json @@ -6,5 +6,5 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, - "include": ["vite.config.ts", "vitest.config.ts", "project-version.ts"] + "include": ["vite.config.ts"] } diff --git a/app/vite.config.ts b/app/vite.config.ts index 46cea5b392..8425a54075 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -5,12 +5,7 @@ import react from '@vitejs/plugin-react-swc'; import { checker } from 'vite-plugin-checker'; import { compression } from 'vite-plugin-compression2'; -import { readProjectVersion } from './project-version'; - export default defineConfig({ - define: { - __APP_VERSION__: JSON.stringify(readProjectVersion()), - }, resolve: { alias: { src: fileURLToPath(new URL('./src', import.meta.url)), diff --git a/app/vitest.config.ts b/app/vitest.config.ts index 42f2a3fa5c..eddaccbabf 100644 --- a/app/vitest.config.ts +++ b/app/vitest.config.ts @@ -2,14 +2,7 @@ import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; -import { readProjectVersion } from './project-version'; - export default defineConfig({ - // Mirrors vite.config.ts — the test run compiles the same `__APP_VERSION__` - // reference in global-config.ts, and vitest does not read vite.config.ts. - define: { - __APP_VERSION__: JSON.stringify(readProjectVersion()), - }, resolve: { alias: { src: fileURLToPath(new URL('./src', import.meta.url)), diff --git a/tests/unit/test_version_sync.py b/tests/unit/test_version_sync.py new file mode 100644 index 0000000000..f3dac3a902 --- /dev/null +++ b/tests/unit/test_version_sync.py @@ -0,0 +1,53 @@ +"""Keep the frontend's version field in step with the project version. + +The masthead on anyplot.ai shows the latest GitHub release tag, and falls back to +`CONFIG.appVersion` whenever that lookup has not answered yet or is unavailable +(offline, rate-limited). That fallback reads `app/package.json`, because the +frontend image is built with `docker build -f app/Dockerfile app` — the build +context is `app/` alone, so `pyproject.toml` is not readable at build time. + +Two version fields therefore exist, and they must not drift: `app/package.json` +sat at 2.0.0 through the whole 3.x line, which is how the site came to advertise +a stale version. This test fails the moment a release bumps one and not the +other; the `Run Tests` CI job runs whenever `pyproject.toml` changes, so every +release PR is covered. +""" + +from __future__ import annotations + +import json +import re +import tomllib +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +PYPROJECT = REPO_ROOT / "pyproject.toml" +APP_PACKAGE_JSON = REPO_ROOT / "app" / "package.json" + +SEMVER = re.compile(r"^\d+\.\d+\.\d+$") + + +def _project_version() -> str: + with PYPROJECT.open("rb") as handle: + return tomllib.load(handle)["project"]["version"] + + +def _app_version() -> str: + return json.loads(APP_PACKAGE_JSON.read_text(encoding="utf-8"))["version"] + + +def test_app_package_json_matches_project_version() -> None: + project_version = _project_version() + app_version = _app_version() + + assert app_version == project_version, ( + f"app/package.json is at {app_version} but pyproject.toml is at {project_version}. " + "Bump both — the masthead falls back to the app/package.json version whenever the " + "GitHub releases lookup is unavailable, so a stale value is shown to visitors. " + "See step 4 of agentic/commands/release.md." + ) + + +def test_project_version_is_a_semver_triple() -> None: + assert SEMVER.match(_project_version())