Files
tai/tai_pkg/release.py
T
retoor 8df5cf7a45 Split monolithic tai.py into tai_pkg package, keep tai-orig.py as original
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.
2026-10-10 19:25:02 +02:00

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