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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion agentic/commands/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):
Expand Down
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
23 changes: 0 additions & 23 deletions app/project-version.ts

This file was deleted.

25 changes: 12 additions & 13 deletions app/src/global-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+$/);
});
});
16 changes: 12 additions & 4 deletions app/src/global-config.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand Down
4 changes: 0 additions & 4 deletions app/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion app/tsconfig.node.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "vitest.config.ts", "project-version.ts"]
"include": ["vite.config.ts"]
}
5 changes: 0 additions & 5 deletions app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
7 changes: 0 additions & 7 deletions app/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
53 changes: 53 additions & 0 deletions tests/unit/test_version_sync.py
Original file line number Diff line number Diff line change
@@ -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())