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:
parent
a371223189
commit
0a2b8c77b8
5 changed files with 147 additions and 10 deletions
1
.git_archival.txt
Normal file
1
.git_archival.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
node: $Format:%H$
|
||||
12
.gitattributes
vendored
12
.gitattributes
vendored
|
|
@ -1,2 +1,14 @@
|
|||
# Cmd.exe parses LF-only .bat files incorrectly on Windows (joined tokens / mangled commands).
|
||||
*.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
1
.gitignore
vendored
|
|
@ -19,6 +19,7 @@ __pycache__
|
|||
!assets/*.png
|
||||
!assets/engine_icons/**
|
||||
!.pre-commit-config.yaml
|
||||
!.git_archival.txt
|
||||
!data/skills/**
|
||||
!data/help/**
|
||||
!data/vocab_base.txt
|
||||
|
|
|
|||
57
gui/main.py
57
gui/main.py
|
|
@ -53,8 +53,7 @@ def check_tool_update() -> str | None:
|
|||
"""Return latest commit SHA when a tool update is available, else None."""
|
||||
try:
|
||||
latest_sha = UpdateThread.fetch_latest_sha()
|
||||
sha_path = Path(UpdateThread.SHA_FILE)
|
||||
current_sha = sha_path.read_text().strip() if sha_path.is_file() else ""
|
||||
current_sha = UpdateThread.read_installed_sha()
|
||||
if latest_sha != current_sha:
|
||||
return latest_sha
|
||||
except Exception:
|
||||
|
|
@ -82,9 +81,21 @@ class UpdateThread(QThread):
|
|||
# "dazedtl"). Resolve dynamically so a rename cannot silently no-op again.
|
||||
ARCHIVE_ROOT = "dazedtl"
|
||||
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
|
||||
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.
|
||||
# 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:
|
||||
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):
|
||||
return self.fetch_latest_sha()
|
||||
|
||||
def _read_stored_sha(self):
|
||||
p = Path(self.SHA_FILE)
|
||||
return p.read_text().strip() if p.exists() else ""
|
||||
return self.read_installed_sha()
|
||||
|
||||
def _download_archive(self, zip_path: Path):
|
||||
req = urllib.request.Request(
|
||||
|
|
@ -493,10 +535,7 @@ class UpdateDialog(QDialog):
|
|||
|
||||
@classmethod
|
||||
def _installed_sha_display(cls) -> str:
|
||||
sha_path = Path(UpdateThread.SHA_FILE)
|
||||
if sha_path.is_file():
|
||||
return cls._short_sha(sha_path.read_text().strip())
|
||||
return "Not recorded"
|
||||
return cls._short_sha(UpdateThread.read_installed_sha())
|
||||
|
||||
def _set_stage_pills(self, active_stage: str | None = None):
|
||||
stage_order = list(UpdateThread.STAGES)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class UpdateThreadInstallFilterTests(unittest.TestCase):
|
|||
|
||||
for rel in (
|
||||
*_SHIPPED_DATA_FILES,
|
||||
".git_archival.txt",
|
||||
"gui/main.py",
|
||||
"gameupdate/GameUpdate.bat",
|
||||
"gameupdate/gameupdate/patch.ps1",
|
||||
|
|
@ -58,6 +59,11 @@ class UpdateThreadInstallFilterTests(unittest.TestCase):
|
|||
"log/translations.txt",
|
||||
"files/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):
|
||||
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):
|
||||
"""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):
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
|
|
@ -207,6 +254,43 @@ class CheckToolUpdateTests(unittest.TestCase):
|
|||
):
|
||||
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):
|
||||
from gui.main import UpdateThread, check_tool_update
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue