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).
|
# 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
1
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
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."""
|
"""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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue