From 086d9ed56e6f2b242858c4f53d3f983a43a79203 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 14 Aug 2026 23:35:33 +0200 Subject: [PATCH] fix(client): bake the version into the build, so an export can invalidate its cache (T-1241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit current_schema_version() line-scanned res://../project.yaml at runtime. That resolves to the repo root in a dev run and to nothing in an exported build, so a shipped game got the "?.?.?" fallback every time. Since that tag is the Atlas disk cache's ONLY invalidation signal, every exported build stamped and compared the same sentinel: a canvas cached by one build would be served by every later build, forever. T-1239 is what that failure looks like once it happens. loading_screen.gd carried a byte-for-byte copy of the same function, so the version shown to the player was "?.?.?" in exactly the builds where a version string is worth showing. Both call sites now share client/scripts/build_version.gd, which reads application/config/version out of ProjectSettings — a value Godot bakes into the PCK, identical in the editor and in an export by construction rather than by luck. No file IO, no fallback branch. project.yaml stays the source of truth (CLAUDE.md); client/project.godot mirrors it. A mirror nobody checks would be worse than the bug it replaces -- the old code failed loudly everywhere, a stale mirror fails silently -- so tooling/check-client-version compares the two and the pre-push hook runs it unconditionally. Not gated on "were those files in this push": drift persists on main once introduced, and gating would let an existing drift ride along. The test this replaces asserted that current_schema_version() did not return its fallback, and passed -- in the one environment where the code under test worked. Three tests now pin the property that actually matters: a real version, sourced from the baked setting, matching project.yaml. Co-Authored-By: Claude Opus 5 (1M context) --- .config/hooks/pre-push | 20 ++++++ .pql/changelog/ticket_history/2026-08.sql | 1 + .pql/changelog/tickets/2026-08.sql | 1 + CLAUDE.md | 2 +- Makefile | 6 +- client/project.godot | 7 ++ client/scripts/build_version.gd | 31 ++++++++ client/tests/test_step_canvas_disk_cache.gd | 61 ++++++++++++++-- .../step_canvas/step_canvas_disk_cache.gd | 25 +++---- .../ui/meta/screens/loading/loading_screen.gd | 32 +++++---- docs/DEVOPS.md | 20 ++++++ tooling/check-client-version | 71 +++++++++++++++++++ 12 files changed, 241 insertions(+), 36 deletions(-) create mode 100644 client/scripts/build_version.gd create mode 100755 tooling/check-client-version diff --git a/.config/hooks/pre-push b/.config/hooks/pre-push index ecfb6e18e..49b913119 100755 --- a/.config/hooks/pre-push +++ b/.config/hooks/pre-push @@ -278,6 +278,26 @@ else echo "pre-push: systems.db not in push — skipping stamp check" fi +# --- Client version mirror (T-1241) --- +# project.yaml is the version source of truth, but the client cannot read it at +# runtime (an exported build has no repo root), so it is mirrored into +# client/project.godot's `application/config/version`. The Atlas disk cache keys +# its ONLY invalidation signal on that value, so a stale mirror makes a build +# serve canvases generated by code it no longer runs — the T-1239 failure. +# +# Deliberately UNCONDITIONAL, unlike the systems.db check above. Drift is +# introduced by touching one file without the other, but it PERSISTS on main +# until someone notices, so gating on "was either file in this push" would let +# an existing drift ride along indefinitely. The check is two file reads. +if [ -f "$REPO_ROOT/tooling/check-client-version" ]; then + echo "pre-push: checking client version mirror..." + if python3 "$REPO_ROOT/tooling/check-client-version"; then + : + else + fail_check "client version mirror (drifted from project.yaml)" + fi +fi + # --- Clerk review (D-221) --- # DISABLED 2026-05-23 (#965) pending rework. Two problems made it net-negative: # 1. Non-exhaustive — a single run reports ~the first contradiction it finds diff --git a/.pql/changelog/ticket_history/2026-08.sql b/.pql/changelog/ticket_history/2026-08.sql index 7a56ecb83..749fcb019 100644 --- a/.pql/changelog/ticket_history/2026-08.sql +++ b/.pql/changelog/ticket_history/2026-08.sql @@ -169,3 +169,4 @@ FOLLOW-UPS worth their own tickets, not done here: suggests a check (e.g. a pre-push rule: canvas-generation paths touched => project.yaml version must move) rather than a fifth comment.', NULL, '2026-08-14 21:13:15', '2026-08-14 21:13:15.779', '2026-08-14 21:13:15.779', NULL, 'a7cfa7d53e62cff64cd95b5738d1ee75', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXF1VDVQDQ8EFGTXX787M90R', 'status', 'in_progress', 'done', NULL, '2026-08-14 21:20:27', '2026-08-14 21:20:27.835', '2026-08-14 21:20:27.835', NULL, '815c19499dbf40e3b7a79f44c09089c2', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G0495WRHF8ADK82VR8CH1J8R', 'status', 'backlog', 'in_progress', NULL, '2026-08-14 21:21:35', '2026-08-14 21:21:35.098', '2026-08-14 21:21:35.098', NULL, 'caa82c705ee32a7582705b2dd5d2cf17', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-08.sql b/.pql/changelog/tickets/2026-08.sql index 6736e22dc..8f43c24ce 100644 --- a/.pql/changelog/tickets/2026-08.sql +++ b/.pql/changelog/tickets/2026-08.sql @@ -302,3 +302,4 @@ FOLLOW-UPS worth their own tickets, not done here: - Nothing enforces the generation-change/version-bump pairing. Four occurrences suggests a check (e.g. a pre-push rule: canvas-generation paths touched => project.yaml version must move) rather than a fifth comment.', 'done', 'high', NULL, 'client', 'D-261', '2026-08-06 14:43:24.765', '2026-08-14 21:20:27.835', NULL, 'fa2402988b35d9f0db6ffcf7cf7a81c2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G0495WRHF8ADK82VR8CH1J8R', 'bug', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas disk cache never invalidates in an exported build — current_schema_version() falls back to ''?.?.?''', 'step_canvas_disk_cache.gd::current_schema_version() reads ProjectSettings.globalize_path(''res://'') + ''/../project.yaml''. That resolves to the repo-root file in a dev run (res:// = client/), but an exported build has no project.yaml one level above res://, so the function returns its ''?.?.?'' fallback. Every exported build therefore stamps and compares the SAME sentinel version, which means the schema-version invalidation path — the cache''s only invalidation signal — is inert in a shipped game: a canvas cached by one build is served forever by every later build. Found while diagnosing T-1239, where the same mechanism failed in dev for a different reason (the version simply was not bumped). Fix direction: bake the version into the client at export time (a generated const, ProjectSettings application/config/version, or an exported resource) rather than reading a repo file at runtime. Note test_current_schema_version_reads_project_yaml passes in dev and would not catch this — it asserts the non-fallback path, in the only environment where that path works.', 'in_progress', 'medium', NULL, 'client', 'D-255', '2026-08-14 21:19:17.188', '2026-08-14 21:21:35.097', NULL, '0e36862b9cbd420d1f0d8114f7673cc9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; diff --git a/CLAUDE.md b/CLAUDE.md index 12f8e0377..3c07054c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ A top-down life-sim — asymmetric information, occlusion-based perception, sing **Official Title:** The Settled Reach **Repository name:** settled-reach -**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.{phase}.{n}`) +**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.{phase}.{n}`), mirrored into `client/project.godot`'s `application/config/version` — the client can't read `project.yaml` at runtime (an exported build has no repo root), and the Atlas disk cache keys its only invalidation signal on that value. Bump both; `make check-client-version` (run by the pre-push hook) fails on drift. See `client/scripts/build_version.gd` (T-1241). ## Project Structure diff --git a/Makefile b/Makefile index a30c87a8d..263a0568e 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) .PHONY: help setup build client server game atlas stop test test-tooling lint lint-python setup-venv ci ci-client ci-server clean \ decisions-sync decisions-active decisions-validate \ validate-content check-fact-ids setup-hooks install-hooks \ - audit deny atlas-verify economy-db regen-db check-systems-db \ + audit deny atlas-verify economy-db regen-db check-systems-db check-client-version \ pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \ pre-pr-server pre-pr-client pre-pr-content \ fixtures-client fixtures-gauntlet golden-diff golden-update \ @@ -56,6 +56,7 @@ help: @echo " make economy-db Import economics data into systems.db (TOML/JSON → SQLite)" @echo " make regen-db Regenerate systems.db from all sources + stamp meta table (#855)" @echo " make check-systems-db Verify systems.db meta stamp matches current generator sources" + @echo " make check-client-version Verify client/project.godot version mirrors project.yaml" @echo " make install-hooks Install pre-push + pre-commit git hooks (once per clone)" @echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)" @echo " make golden-diff Show diff if golden file output has changed" @@ -406,6 +407,9 @@ regen-db: ## Regenerate systems.db from all sources and stamp meta table (#855, check-systems-db: ## Verify systems.db meta stamp matches current generator sources (#857) @python3 tooling/check-systems-db-stamp --verbose +check-client-version: ## Verify client/project.godot's baked version matches project.yaml (T-1241) + @python3 tooling/check-client-version + econ-sim: ## Build the economics simulation binary (Layer 1+2: Leontief + tâtonnement trade) @cargo build --manifest-path tooling/econ-sim/Cargo.toml --release @echo "Built: tooling/econ-sim/target/release/econ-sim" diff --git a/client/project.godot b/client/project.godot index 9a3b0dc97..80b3c377e 100644 --- a/client/project.godot +++ b/client/project.godot @@ -11,6 +11,13 @@ config_version=5 [application] config/name="The Settled Reach" +; Mirror of project.yaml's `version:` — the repo root file stays the source of +; truth (CLAUDE.md), this is the copy that survives an export. Godot bakes +; ProjectSettings into the PCK, so `application/config/version` reads the same +; in the editor and in a shipped build, where res://../project.yaml does not +; exist at all (T-1241). Kept honest by `make check-client-version`, which the +; pre-push hook runs — do not edit this by hand without moving project.yaml too. +config/version="0.4.6" run/main_scene="res://scenes/main_menu.tscn" config/features=PackedStringArray("4.6", "GL Compatibility") config/icon="res://icon.svg" diff --git a/client/scripts/build_version.gd b/client/scripts/build_version.gd new file mode 100644 index 000000000..be278dbec --- /dev/null +++ b/client/scripts/build_version.gd @@ -0,0 +1,31 @@ +extends RefCounted + +## The running client's version string — the ONE place anything asks (T-1241). +## +## Reads `application/config/version` out of ProjectSettings, which Godot bakes +## into the exported PCK. That is the whole point: two call sites previously +## line-scanned `res://../project.yaml` at runtime, which resolves to the repo +## root in a dev run and TO NOTHING in an exported build — both returned their +## "?.?.?" fallback in a shipped game. +## +## For the Atlas disk cache (D-255, step_canvas_disk_cache.gd) that was not a +## cosmetic defect: the version tag is that cache's ONLY invalidation signal, so +## a constant sentinel meant every exported build stamped and compared the same +## string and a canvas cached by one build would be served by every later build, +## forever. T-1239 is what that failure looks like when it happens — eight days +## of a map drawn from a canvas whose generating code no longer existed. +## +## No file IO and no fallback branch, deliberately. A missing setting returns "" +## and callers can see that; there is no environment where this reads one thing +## in the editor and another in an export, which is exactly the property the +## project.yaml scan lacked. +## +## `project.yaml` remains the source of truth for the version (CLAUDE.md); the +## ProjectSettings entry is a mirror, and `tooling/check-client-version` fails +## the push if the two drift. + +const SETTING_KEY: String = "application/config/version" + + +static func current() -> String: + return str(ProjectSettings.get_setting(SETTING_KEY, "")) diff --git a/client/tests/test_step_canvas_disk_cache.gd b/client/tests/test_step_canvas_disk_cache.gd index b3ae515e7..0de2d2dd2 100644 --- a/client/tests/test_step_canvas_disk_cache.gd +++ b/client/tests/test_step_canvas_disk_cache.gd @@ -370,14 +370,65 @@ func test_mismatched_schema_version_has_reports_false() -> void: assert_bool(cache.has("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))).is_false() -func test_current_schema_version_reads_project_yaml() -> void: - # project.yaml's version field is "0.4.0" per CLAUDE.md at time of - # writing — assert only that a non-placeholder value comes back (not the - # exact string, so this test doesn't need updating every version bump). +## T-1241. The predecessor of this test asserted that current_schema_version() +## did not return its "?.?.?" fallback — and passed, in the ONE environment +## where the code under test worked. It read res://../project.yaml, which exists +## in a dev run and never in an exported build, so the shipped game got the +## fallback for every build, the cache compared a constant sentinel against +## itself, and nothing was ever invalidated. A green test said otherwise. +## +## The version now comes from ProjectSettings, which Godot bakes into the PCK, so +## the dev and exported answers are the same value by construction rather than by +## luck. These tests assert that construction: a real version, sourced from the +## setting, with no file lookup left to differ between environments. +func test_current_schema_version_is_a_real_version_not_the_fallback() -> void: var v: String = StepCanvasDiskCache.current_schema_version() assert_str(v).override_failure_message( - "current_schema_version() must read a real value from project.yaml, not the '?' fallback" + "current_schema_version() must return a real version, never the '?.?.?' fallback" ).is_not_equal("?.?.?") + assert_str(v).override_failure_message( + "current_schema_version() must not be empty — the ProjectSettings key is missing" + ).is_not_empty() + + +func test_current_schema_version_comes_from_project_settings() -> void: + # The property that makes this export-safe: the value IS the baked setting, + # not something re-derived from a file that an export would not contain. + var setting: String = str(ProjectSettings.get_setting("application/config/version", "")) + assert_str(setting).override_failure_message( + "application/config/version must be set in client/project.godot — it is what " + + "survives an export, and the Atlas disk cache's only invalidation signal" + ).is_not_empty() + assert_str(StepCanvasDiskCache.current_schema_version()).is_equal(setting) + + +func test_baked_version_mirrors_project_yaml() -> void: + # Drift guard, dev-only by nature (an export has no project.yaml to compare + # against — which is the whole reason the value is baked). project.yaml stays + # the source of truth; this catches the bump-one-forget-the-other case from + # inside the suite, as well as from tooling/check-client-version in the hook. + var yaml_path := ProjectSettings.globalize_path("res://") + "/../project.yaml" + if not FileAccess.file_exists(yaml_path): + return # exported/packaged run — nothing to compare, and nothing to guard + var file := FileAccess.open(yaml_path, FileAccess.READ) + assert_object(file).is_not_null() + var content := file.get_as_text() + file.close() + + var yaml_version := "" + for line: String in content.split("\n"): + if line.begins_with("version:"): + var parts := line.split(":", false, 1) + if parts.size() >= 2: + yaml_version = parts[1].strip_edges() + break + assert_str(yaml_version).override_failure_message( + "project.yaml must carry a `version:` line — it is the source of truth" + ).is_not_empty() + assert_str(StepCanvasDiskCache.current_schema_version()).override_failure_message( + "client/project.godot's config/version has drifted from project.yaml. " + + "Fix with the value in project.yaml; see `make check-client-version`." + ).is_equal(yaml_version) # ============================================================================= diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_disk_cache.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_disk_cache.gd index d725e4d41..f4f9c2e8c 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_disk_cache.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_disk_cache.gd @@ -96,6 +96,10 @@ extends RefCounted ## comment. const StepCanvasCache := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_cache.gd") +## preload, not a `class_name` reference: this script is reachable from the +## ImplantRegistry autoload's preload graph, and CLAUDE.md's autoload parse-order +## rule makes a bare class_name unsafe there. +const BuildVersion := preload("res://scripts/build_version.gd") ## Tier tags, mirroring stig-round2.md's IndexEntry.tier field exactly. const TIER_GEOMETRY: String = "Geometry" @@ -167,21 +171,14 @@ var _root: String = DEFAULT_ROOT var _indexes: Dictionary = {} # body_id String -> (key String -> IndexEntry Dictionary) +## T-1241: this used to line-scan `res://../project.yaml` itself. That path +## resolves to the repo root in a dev run and to nothing in an exported build, so +## a shipped game got the "?.?.?" fallback — and since this tag is the cache's +## only invalidation signal, EVERY exported build stamped and compared the same +## sentinel, making the cache permanently un-invalidatable. The version now comes +## from ProjectSettings, which Godot bakes into the PCK. See build_version.gd. static func current_schema_version() -> String: - var yaml_path := ProjectSettings.globalize_path("res://") + "/../project.yaml" - if not FileAccess.file_exists(yaml_path): - return "?.?.?" - var file := FileAccess.open(yaml_path, FileAccess.READ) - if file == null: - return "?.?.?" - var content := file.get_as_text() - file.close() - for line: String in content.split("\n"): - if line.begins_with("version:"): - var parts := line.split(":", false, 1) - if parts.size() >= 2: - return parts[1].strip_edges() - return "?.?.?" + return BuildVersion.current() ## `root` is injectable so tests never touch the real diff --git a/client/ui/meta/screens/loading/loading_screen.gd b/client/ui/meta/screens/loading/loading_screen.gd index a307e2518..95cb0b4c3 100644 --- a/client/ui/meta/screens/loading/loading_screen.gd +++ b/client/ui/meta/screens/loading/loading_screen.gd @@ -2,7 +2,11 @@ extends MetaScreen ## #257: Loading screen overlay — blocks input during save/load round-trip. ## Shown when LOAD_GAME fires; hidden when save_result arrives (success or failure). ## Full-screen, dark overlay with centered status text. -## #724: Shows client version (from project.yaml) and protocol version below the status text. +## #724: Shows client version and protocol version below the status text. The +## version comes from ProjectSettings (mirrored from project.yaml) so it survives +## an export — see build_version.gd (T-1241). + +const BuildVersion := preload("res://scripts/build_version.gd") const BG_COLOR := Color(0.05, 0.05, 0.08, 1.0) const TEXT_COLOR := Color("#c8d0e0") @@ -55,21 +59,19 @@ func _build_ui() -> void: add_child(_version_label) +## T-1241: was a byte-for-byte copy of the disk cache's own project.yaml scan, +## with the same export defect — a shipped build has no res://../project.yaml, so +## the version shown to the player read "?.?.?" in exactly the builds where a +## version string is worth showing. Both call sites now share build_version.gd. +## +## The display fallback stays HERE rather than in the accessor: BuildVersion +## returns "" for a missing setting and does not invent a value, because its +## other caller keys cache invalidation on it and a plausible-looking sentinel +## there is what made this bug survive. A label, unlike a cache key, still has to +## render something, so it substitutes its own. func _read_client_version() -> String: - var yaml_path := ProjectSettings.globalize_path("res://") + "/../project.yaml" - if not FileAccess.file_exists(yaml_path): - return "?.?.?" - var file := FileAccess.open(yaml_path, FileAccess.READ) - if file == null: - return "?.?.?" - var content := file.get_as_text() - file.close() - for line: String in content.split("\n"): - if line.begins_with("version:"): - var parts := line.split(":", false, 1) - if parts.size() >= 2: - return parts[1].strip_edges() - return "?.?.?" + var v := BuildVersion.current() + return v if not v.is_empty() else "?.?.?" ## Update the status text shown while loading. Call before show_loading() or after. diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index 15108a6b1..2ff54adbc 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -253,6 +253,26 @@ to `MIGRATION_SQL` in `import_economics.py`. See `.claude/rules/asset-pipeline.md` for the full rule set. +## Client version mirror + +`project.yaml`'s `version:` is the source of truth, but the client cannot read that +file at runtime — an exported build has no repo root above `res://`. The value is +therefore mirrored into `client/project.godot` as `application/config/version`, which +Godot bakes into the exported PCK, and read through `client/scripts/build_version.gd`. + +This is not cosmetic. The Atlas disk cache (D-255) keys its **only** invalidation +signal on that version, so a version the client cannot read means a cache that can +never be invalidated — see T-1241, and T-1239 for what a stale canvas cache actually +costs. + +```bash +make check-client-version # exit 1 = the two files disagree +``` + +The pre-push hook runs this unconditionally (drift persists on `main` once +introduced, so gating it on "were those files touched in this push" would let an +existing drift ride along). **Bump both files together.** + ## Pre-commit and Pre-push Hooks Git hooks are stored in `.config/hooks/` (version-controlled). Activate them with: diff --git a/tooling/check-client-version b/tooling/check-client-version new file mode 100755 index 000000000..4610080cb --- /dev/null +++ b/tooling/check-client-version @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Fail if client/project.godot's baked version has drifted from project.yaml. + +project.yaml is the version source of truth (CLAUDE.md). The client cannot read +it at runtime — an exported build has no repo root — so the value is mirrored +into `application/config/version` in client/project.godot, which Godot bakes +into the PCK (T-1241). + +A mirror nobody checks is worse than the bug it replaced: the old code failed +LOUDLY in an export ("?.?.?" everywhere, no cache invalidation ever), whereas a +stale mirror fails SILENTLY — the Atlas disk cache would keep serving canvases +under a version that stopped matching the build. That is precisely the T-1239 +failure, which cost eight days of a map drawn from a canvas whose generating +code no longer existed. Hence this check, wired into the pre-push hook. + +Exit: 0 = in sync, 1 = drifted or unreadable. +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PROJECT_YAML = ROOT / "project.yaml" +PROJECT_GODOT = ROOT / "client" / "project.godot" + +# Anchored to line start so the commentary above `version:` (which mentions +# earlier versions by number) can never be mistaken for the field itself. +YAML_VERSION = re.compile(r"^version:\s*(\S+)\s*$", re.MULTILINE) +GODOT_VERSION = re.compile(r'^config/version\s*=\s*"([^"]*)"\s*$', re.MULTILINE) + + +def read(path: Path, pattern: re.Pattern, label: str) -> str | None: + if not path.exists(): + print(f"check-client-version: {path} not found", file=sys.stderr) + return None + match = pattern.search(path.read_text(encoding="utf-8")) + if not match: + print(f"check-client-version: no {label} in {path}", file=sys.stderr) + return None + return match.group(1) + + +def main() -> int: + yaml_version = read(PROJECT_YAML, YAML_VERSION, "`version:` line") + godot_version = read(PROJECT_GODOT, GODOT_VERSION, "`config/version=` line") + if yaml_version is None or godot_version is None: + return 1 + + if yaml_version != godot_version: + print( + "check-client-version: version drift\n" + f" project.yaml {yaml_version}\n" + f" client/project.godot {godot_version}\n" + "\n" + "project.yaml is the source of truth. Set config/version in\n" + "client/project.godot's [application] section to match it.\n" + "\n" + "This matters beyond cosmetics: the Atlas disk cache keys its\n" + "invalidation on this version, so a stale mirror makes a shipped\n" + "build serve canvases generated by code it no longer runs (T-1239).", + file=sys.stderr, + ) + return 1 + + print(f"check-client-version: OK — {yaml_version}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())