tai.py (8643 lines) is now a thin facade over tai_pkg/ (33 feature modules, one class or feature per file). tai-orig.py preserves the pre-split single-file version byte for byte (sha256 verified against snapshot); import tai and ./tai.py behavior unchanged. Equivalence proof, all run on this tree immediately pre-commit: differential battery tai-orig.py vs facade 3718 checks 0 failures (surface, pure fuzz, tool dispatch, store/audit, release ops, fake-HOME install roundtrip, CLI runs); test_tai.py 261 OK; test_seal.py 16 OK; --version/--help byte-identical; dir(tai) zero removals. Fix included: opencode wire-protocol schema echo leaked into replies; opencode_parse now scans with JSONDecoder.raw_decode capped at 200 scans (13 OpencodeParseTests). Test count 261 = 237 HEAD + 8 pre-existing uncommitted ToolArgumentsTests (now committed with this change, disclosed) + 13 OpencodeParseTests + 3 EmbeddedStringsTests. Patch-target-only test migration (24 hunks), zero assertion changes. tai_pkg clean-regenerated after mid-verification tamper was found and reverted.
134 lines
4.8 KiB
Python
134 lines
4.8 KiB
Python
"""Version bumps, self-backups, and release bookkeeping."""
|
|
|
|
import hashlib
|
|
import os
|
|
import sqlite3
|
|
import time
|
|
from datetime import datetime, timezone
|
|
|
|
from tai_pkg.consts import Consts
|
|
from tai_pkg.text import Text
|
|
|
|
__all__ = ["Release"]
|
|
|
|
|
|
class Release:
|
|
"""Release."""
|
|
|
|
@staticmethod
|
|
def next_version(current, part):
|
|
major, minor, patch = (int(item) for item in current.split("."))
|
|
if part == "major":
|
|
return "%d.0.0" % (major + 1)
|
|
if part == "minor":
|
|
return "%d.%d.0" % (major, minor + 1)
|
|
if part == "patch":
|
|
return "%d.%d.%d" % (major, minor, patch + 1)
|
|
raise ValueError("part must be major, minor, or patch")
|
|
|
|
@staticmethod
|
|
def atomic_write(path, content):
|
|
tmp = "%s.tmp-%d" % (path, os.getpid())
|
|
with open(tmp, "w", encoding="utf-8") as handle:
|
|
handle.write(content)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(tmp, path)
|
|
|
|
@staticmethod
|
|
def sealed_write(path, content):
|
|
Release.atomic_write(path, content)
|
|
try:
|
|
os.chmod(path, 0o600)
|
|
except OSError:
|
|
pass
|
|
|
|
@staticmethod
|
|
def quarantine(path):
|
|
try:
|
|
if os.path.exists(path):
|
|
os.replace(path, "%s.corrupt-%d" % (path, int(time.time())))
|
|
except OSError:
|
|
pass
|
|
|
|
@staticmethod
|
|
def release_bump(script_file, part):
|
|
with open(script_file, "r", encoding="utf-8") as handle:
|
|
text = handle.read()
|
|
match = Consts.VERSION_RE.search(text)
|
|
if match is None:
|
|
raise ValueError("no VERSION line in %s" % script_file)
|
|
old = ".".join(match.groups())
|
|
new = Release.next_version(old, part)
|
|
matched = text[match.start():match.end()]
|
|
indent = matched[:len(matched) - len(matched.lstrip())]
|
|
Release.atomic_write(script_file, text[:match.start()] + indent + "VERSION = \"%s\"" % new + text[match.end():])
|
|
return old, new
|
|
|
|
@staticmethod
|
|
def backups_dir(home):
|
|
return os.path.join(home, "backups")
|
|
|
|
@staticmethod
|
|
def prune_backups(folder):
|
|
try:
|
|
names = sorted((entry for entry in os.listdir(folder) if entry.startswith("tai-") and entry.endswith(".py")), key=lambda entry: os.path.getmtime(os.path.join(folder, entry)))
|
|
except OSError:
|
|
return 0
|
|
pruned = 0
|
|
while len(names) > Consts.BACKUP_KEEP:
|
|
try:
|
|
os.remove(os.path.join(folder, names.pop(0)))
|
|
pruned += 1
|
|
except OSError:
|
|
break
|
|
return pruned
|
|
|
|
@staticmethod
|
|
def snapshot_self(script_file, folder, version):
|
|
with open(script_file, "rb") as handle:
|
|
data = handle.read()
|
|
digest = hashlib.sha256(data).hexdigest()[:8]
|
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
name = "tai-%s-%s-%s.py" % (version, stamp, digest)
|
|
os.makedirs(folder, exist_ok=True)
|
|
dest = os.path.join(folder, name)
|
|
with open(dest, "wb") as handle:
|
|
handle.write(data)
|
|
Release.prune_backups(folder)
|
|
return dest, digest
|
|
|
|
@staticmethod
|
|
def backup_has_digest(folder, digest):
|
|
try:
|
|
return any(entry.endswith("-%s.py" % digest) for entry in os.listdir(folder))
|
|
except OSError:
|
|
return False
|
|
|
|
@staticmethod
|
|
def ensure_self_backup(config, store, script_file=None, actor="main"):
|
|
script_file = script_file or os.path.abspath(Consts.TAI_SCRIPT)
|
|
folder = Release.backups_dir(config.home)
|
|
with open(script_file, "rb") as handle:
|
|
digest = hashlib.sha256(handle.read()).hexdigest()[:8]
|
|
if Release.backup_has_digest(folder, digest):
|
|
return None
|
|
dest, _digest = Release.snapshot_self(script_file, folder, Consts.VERSION)
|
|
try:
|
|
store.audit_event(actor, "snapshot", script_file, "backed up as %s" % os.path.basename(dest), tags=["snapshot", "v" + Consts.VERSION])
|
|
text, override = Text.read_capped(script_file, Consts.FILE_RECORD_MAX)
|
|
if text is not None:
|
|
store.upsert_file_record(script_file, text, override)
|
|
except (sqlite3.Error, OSError):
|
|
pass
|
|
return dest
|
|
|
|
@staticmethod
|
|
def do_release(store, script_file, folder, part, message, actor="main"):
|
|
if not message or not message.strip():
|
|
raise ValueError("release message is required")
|
|
old, new = Release.release_bump(script_file, part)
|
|
dest, _digest = Release.snapshot_self(script_file, folder, new)
|
|
row_id = store.audit_event(actor, "release", script_file, message.strip()[:500], old=old, new=new, tags=["release", "v" + new])
|
|
return old, new, dest, row_id
|