fix(update): identify source archives and exclude dev metadata

- Embed the archived commit SHA to prevent false first-launch updates
- Exclude editor and agent directories from source and update archives
- Add coverage for archive metadata and installer filtering
This commit is contained in:
DazedAnon 2026-07-24 12:18:18 -05:00
parent a371223189
commit 0a2b8c77b8
5 changed files with 147 additions and 10 deletions

1
.git_archival.txt Normal file
View file

@ -0,0 +1 @@
node: $Format:%H$

12
.gitattributes vendored
View file

@ -1,2 +1,14 @@
# Cmd.exe parses LF-only .bat files incorrectly on Windows (joined tokens / mangled commands). # Cmd.exe parses LF-only .bat files incorrectly on Windows (joined tokens / mangled commands).
*.bat text eol=crlf *.bat text eol=crlf
# Keep development-only agent/editor metadata out of user-facing source and
# update archives.
.agents export-ignore
.codex export-ignore
.cursor export-ignore
.github export-ignore
.vscode export-ignore
# Expand the exact archived commit so a fresh source download can identify the
# version it already contains without committing a self-referential literal SHA.
.git_archival.txt export-subst

1
.gitignore vendored
View file

@ -19,6 +19,7 @@ __pycache__
!assets/*.png !assets/*.png
!assets/engine_icons/** !assets/engine_icons/**
!.pre-commit-config.yaml !.pre-commit-config.yaml
!.git_archival.txt
!data/skills/** !data/skills/**
!data/help/** !data/help/**
!data/vocab_base.txt !data/vocab_base.txt

View file

@ -53,8 +53,7 @@ def check_tool_update() -> str | None:
"""Return latest commit SHA when a tool update is available, else None.""" """Return latest commit SHA when a tool update is available, else None."""
try: try:
latest_sha = UpdateThread.fetch_latest_sha() latest_sha = UpdateThread.fetch_latest_sha()
sha_path = Path(UpdateThread.SHA_FILE) current_sha = UpdateThread.read_installed_sha()
current_sha = sha_path.read_text().strip() if sha_path.is_file() else ""
if latest_sha != current_sha: if latest_sha != current_sha:
return latest_sha return latest_sha
except Exception: except Exception:
@ -82,9 +81,21 @@ class UpdateThread(QThread):
# "dazedtl"). Resolve dynamically so a rename cannot silently no-op again. # "dazedtl"). Resolve dynamically so a rename cannot silently no-op again.
ARCHIVE_ROOT = "dazedtl" ARCHIVE_ROOT = "dazedtl"
SHA_FILE = str(LAST_UPDATE_SHA_PATH) SHA_FILE = str(LAST_UPDATE_SHA_PATH)
ARCHIVE_SHA_FILE = str(PROJECT_ROOT / ".git_archival.txt")
# Top-level paths that should never be touched during update # Top-level paths that should never be touched during update
PROTECTED_TOP = {".env", "venv", "log", "files", "translated"} PROTECTED_TOP = {
".agents",
".codex",
".cursor",
".env",
".github",
".vscode",
"venv",
"log",
"files",
"translated",
}
# User-local files under data/ that must not be overwritten. # User-local files under data/ that must not be overwritten.
# Shipped defaults (translation_contexts.json, skills/*.md, help/*, vocab_base.txt, …) # Shipped defaults (translation_contexts.json, skills/*.md, help/*, vocab_base.txt, …)
@ -185,14 +196,45 @@ class UpdateThread(QThread):
with urllib.request.urlopen(req, timeout=15) as resp: with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())["commit"]["id"] return json.loads(resp.read())["commit"]["id"]
@staticmethod
def _read_archive_sha(path: Path) -> str:
"""Read an expanded git-archive commit ID, or return an empty string."""
if not path.is_file():
return ""
try:
for line in path.read_text(encoding="utf-8").splitlines():
key, separator, value = line.partition(":")
candidate = value.strip()
if (
separator
and key.strip() == "node"
and re.fullmatch(r"[0-9a-fA-F]{40,64}", candidate)
):
return candidate
except OSError:
pass
return ""
@classmethod
def read_installed_sha(cls) -> str:
"""Return the updated SHA, falling back to source-archive metadata."""
runtime_path = Path(cls.SHA_FILE)
if runtime_path.is_file():
try:
runtime_sha = runtime_path.read_text(encoding="utf-8").strip()
if runtime_sha:
return runtime_sha
except OSError:
pass
return cls._read_archive_sha(Path(cls.ARCHIVE_SHA_FILE))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _fetch_latest_sha(self): def _fetch_latest_sha(self):
return self.fetch_latest_sha() return self.fetch_latest_sha()
def _read_stored_sha(self): def _read_stored_sha(self):
p = Path(self.SHA_FILE) return self.read_installed_sha()
return p.read_text().strip() if p.exists() else ""
def _download_archive(self, zip_path: Path): def _download_archive(self, zip_path: Path):
req = urllib.request.Request( req = urllib.request.Request(
@ -493,10 +535,7 @@ class UpdateDialog(QDialog):
@classmethod @classmethod
def _installed_sha_display(cls) -> str: def _installed_sha_display(cls) -> str:
sha_path = Path(UpdateThread.SHA_FILE) return cls._short_sha(UpdateThread.read_installed_sha())
if sha_path.is_file():
return cls._short_sha(sha_path.read_text().strip())
return "Not recorded"
def _set_stage_pills(self, active_stage: str | None = None): def _set_stage_pills(self, active_stage: str | None = None):
stage_order = list(UpdateThread.STAGES) stage_order = list(UpdateThread.STAGES)

View file

@ -37,6 +37,7 @@ class UpdateThreadInstallFilterTests(unittest.TestCase):
for rel in ( for rel in (
*_SHIPPED_DATA_FILES, *_SHIPPED_DATA_FILES,
".git_archival.txt",
"gui/main.py", "gui/main.py",
"gameupdate/GameUpdate.bat", "gameupdate/GameUpdate.bat",
"gameupdate/gameupdate/patch.ps1", "gameupdate/gameupdate/patch.ps1",
@ -58,6 +59,11 @@ class UpdateThreadInstallFilterTests(unittest.TestCase):
"log/translations.txt", "log/translations.txt",
"files/Map001.json", "files/Map001.json",
"translated/Map001.json", "translated/Map001.json",
".agents/skills/shipped-data-assets/SKILL.md",
".codex/config.toml",
".cursor/rules/project.mdc",
".github/workflows/tests.yml",
".vscode/settings.json",
): ):
with self.subTest(rel=rel): with self.subTest(rel=rel):
self.assertFalse(UpdateThread.should_install(Path(rel))) self.assertFalse(UpdateThread.should_install(Path(rel)))
@ -180,8 +186,49 @@ class ShippedDataTrackingTests(unittest.TestCase):
) )
class SourceArchiveMetadataTests(unittest.TestCase):
"""Source archives identify their commit and omit development metadata."""
def test_archival_info_is_configured_for_sha_expansion(self):
archival_info = _REPO_ROOT / ".git_archival.txt"
self.assertTrue(archival_info.is_file())
self.assertIn("$Format:%H$", archival_info.read_text(encoding="utf-8"))
ignored = subprocess.run(
["git", "check-ignore", "--", ".git_archival.txt"],
cwd=_REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
self.assertNotEqual(ignored.returncode, 0)
result = subprocess.run(
["git", "check-attr", "export-subst", "--", ".git_archival.txt"],
cwd=_REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0)
self.assertIn("export-subst: set", result.stdout)
def test_development_directories_are_export_ignored(self):
for rel in (".agents", ".codex", ".cursor", ".github", ".vscode"):
with self.subTest(rel=rel):
result = subprocess.run(
["git", "check-attr", "export-ignore", "--", rel],
cwd=_REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0)
self.assertIn("export-ignore: set", result.stdout)
class CheckToolUpdateTests(unittest.TestCase): class CheckToolUpdateTests(unittest.TestCase):
"""Tool update check compares remote SHA to data/last_update_sha.txt only.""" """Tool update checks runtime state, then source-archive commit metadata."""
def test_returns_sha_when_remote_differs(self): def test_returns_sha_when_remote_differs(self):
with tempfile.TemporaryDirectory() as raw: with tempfile.TemporaryDirectory() as raw:
@ -207,6 +254,43 @@ class CheckToolUpdateTests(unittest.TestCase):
): ):
self.assertIsNone(check_tool_update()) self.assertIsNone(check_tool_update())
def test_archive_sha_prevents_false_update_on_first_launch(self):
archived_sha = "a" * 40
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
sha_file = base / "missing-last-update-sha.txt"
archive_file = base / ".git_archival.txt"
archive_file.write_text(f"node: {archived_sha}\n", encoding="utf-8")
from gui.main import UpdateThread, check_tool_update
with patch.multiple(
UpdateThread,
SHA_FILE=str(sha_file),
ARCHIVE_SHA_FILE=str(archive_file),
):
with patch.object(
UpdateThread, "fetch_latest_sha", return_value=archived_sha
):
self.assertIsNone(check_tool_update())
def test_unexpanded_archive_sha_is_ignored(self):
latest_sha = "b" * 40
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
archive_file = base / ".git_archival.txt"
archive_file.write_text("node: $Format:%H$\n", encoding="utf-8")
from gui.main import UpdateThread, check_tool_update
with patch.multiple(
UpdateThread,
SHA_FILE=str(base / "missing-last-update-sha.txt"),
ARCHIVE_SHA_FILE=str(archive_file),
):
with patch.object(
UpdateThread, "fetch_latest_sha", return_value=latest_sha
):
self.assertEqual(check_tool_update(), latest_sha)
def test_returns_none_on_fetch_failure(self): def test_returns_none_on_fetch_failure(self):
from gui.main import UpdateThread, check_tool_update from gui.main import UpdateThread, check_tool_update