Files
JWeBot/jwebot_release_update.py
T
JWeBot Contributors e6d6ddf1c6
check / local-gate (push) Failing after 15s
Release beta 1.2.0.1019
2026-10-19 17:09:40 +08:00

714 lines
23 KiB
Python

#!/usr/bin/env python3
"""Apply the mandatory release lifecycle notices, grant, and announcement."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sqlite3
import subprocess
import sys
from collections.abc import Callable
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent
sys.path[:0] = [
str(ROOT / "packages" / "jwebot-official-extensions" / "src"),
str(ROOT / "packages" / "jwebot-sdk" / "src"),
]
from jwebot_official_extensions import __version__ as OFFICIAL_VERSION # noqa: E402
from jwebot_official_extensions.player_games import ( # noqa: E402
append_coin_event,
append_primogem_event,
normalize_player_account,
)
COMPENSATION_AMOUNT = 200
PRIMOGEM_COMPENSATION_RELEASES = {
"beta 1.0.5.1016": 1,
"beta 1.1.0.1016": 1,
"beta 1.2.0.1019": 1,
}
MAX_BALANCE = 1_000_000_000
MAX_SUMMARY_CHARS = 300
MAX_ANNOUNCEMENT_CHARS = 900
MAX_LIFECYCLE_CHARS = 300
LIFECYCLE_STAGES = frozenset({"shutdown", "startup"})
RELEASE_PATTERN = re.compile(
r"^beta (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\.(?P<date>\d{4})$"
)
CHANGELOG_HEADING = re.compile(
r"^##\s+(?P<release>beta \d+\.\d+\.\d+\.\d{4})"
r"(?:\s+-\s+\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}:\d{2})?)?\s*$"
)
class ReleaseUpdateError(RuntimeError):
"""Raised when release settlement cannot complete safely."""
class AnnouncementUncertainError(ReleaseUpdateError):
"""Raised when an announcement may have been sent without a final receipt."""
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def isoformat_utc(value: datetime) -> str:
return value.astimezone(timezone.utc).isoformat(
timespec="milliseconds"
).replace("+00:00", "Z")
def release_to_pep440(release: str) -> str:
match = RELEASE_PATTERN.fullmatch(release)
if match is None:
raise ReleaseUpdateError(f"unsupported release format: {release}")
return (
f"{match.group('major')}.{match.group('minor')}."
f"{match.group('patch')}b1+{match.group('date')}"
)
def read_release(path: Path) -> str:
try:
release = path.read_text(encoding="utf-8").strip()
except OSError as error:
raise ReleaseUpdateError("cannot read VERSION") from error
release_to_pep440(release)
return release
def read_changelog_summary(path: Path, release: str) -> str:
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError as error:
raise ReleaseUpdateError("cannot read CHANGELOG.md") from error
section_index: int | None = None
for index, line in enumerate(lines):
match = CHANGELOG_HEADING.fullmatch(line)
if match is not None and match.group("release") == release:
section_index = index
break
if section_index is None:
raise ReleaseUpdateError("release is missing from CHANGELOG.md")
parts: list[str] = []
collecting = False
for line in lines[section_index + 1 :]:
if line.startswith("## "):
break
if line.startswith("- "):
if collecting:
break
collecting = True
parts.append(line[2:].strip())
continue
if collecting:
if line.startswith(" "):
parts.append(line.strip())
continue
if not line.strip():
break
summary = " ".join(" ".join(parts).split())
if not summary:
raise ReleaseUpdateError("release changelog needs a first summary bullet")
if len(summary) > MAX_SUMMARY_CHARS:
raise ReleaseUpdateError("release changelog summary is too long")
return summary
def build_announcement(release: str, summary: str) -> str:
primogems = primogem_compensation_amount(release)
extra = f",原石 x{primogems}" if primogems else ""
announcement = (
"亲爱的群友:\n"
f"{release} 更新现已完成。\n"
f"更新内容:{summary}\n"
f"更新补偿:摩拉 x{COMPENSATION_AMOUNT}{extra}\n"
"补偿范围:截至本公告发布前已创建摩拉账户的玩家\n"
"发放方式:补偿已直接到账,无需领取\n"
"感谢大家对 Taffy 的支持与理解,祝大家游戏愉快!"
)
if len(announcement) > MAX_ANNOUNCEMENT_CHARS:
raise ReleaseUpdateError("release announcement is too long")
return announcement
def build_lifecycle_message(release: str, stage: str) -> str:
if stage == "shutdown":
message = (
f"Taffy 准备停机更新到 {release},会短暂离线一下;"
"别慌,我很快回来喵"
)
elif stage == "startup":
message = (
f"Taffy 刚上线,正在做 {release} 的最后自检;"
"确认就绪后会发送更新内容和补偿说明喵"
)
else:
raise ReleaseUpdateError(f"unsupported lifecycle stage: {stage}")
if len(message) > MAX_LIFECYCLE_CHARS:
raise ReleaseUpdateError("release lifecycle message is too long")
return message
def compensation_key(release: str) -> str:
return f"release_compensation:{release}"
def primogem_compensation_amount(release: str) -> int:
return int(PRIMOGEM_COMPENSATION_RELEASES.get(release, 0))
def announcement_key(release: str, group_fingerprint: str) -> str:
group_hash = hashlib.sha256(group_fingerprint.encode()).hexdigest()
return f"release_announcement:{release}:{group_hash}"
def release_slug(release: str) -> str:
return re.sub(r"[^a-zA-Z0-9]+", "-", release).strip("-").lower()
def release_groups(database_path: Path) -> list[str]:
connection = sqlite3.connect(f"file:{database_path}?mode=ro", uri=True)
try:
if connection.execute("PRAGMA quick_check").fetchone()[0] != "ok":
raise ReleaseUpdateError("Edge SQLite integrity check failed")
rows = connection.execute(
"""
SELECT DISTINCT group_fingerprint
FROM player_accounts
ORDER BY group_fingerprint
"""
).fetchall()
finally:
connection.close()
return [str(row[0]) for row in rows]
def checked_marker(raw: str, release: str) -> dict[str, Any]:
expected_primogems = primogem_compensation_amount(release)
try:
marker = json.loads(raw)
except json.JSONDecodeError as error:
raise ReleaseUpdateError("release compensation marker is invalid") from error
if (
not isinstance(marker, dict)
or marker.get("release") != release
or marker.get("amount") != COMPENSATION_AMOUNT
or marker.get("mora_amount") not in {None, COMPENSATION_AMOUNT}
or marker.get("primogem_amount") not in {None, expected_primogems}
or not isinstance(marker.get("group_fingerprints"), list)
):
raise ReleaseUpdateError("release compensation marker does not match")
return marker
def backup_database(
source: sqlite3.Connection,
backup_dir: Path,
release: str,
now: datetime,
) -> Path:
backup_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(backup_dir, 0o700)
stamp = now.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
destination = backup_dir / (
f"edge-before-{release_slug(release)}-compensation-{stamp}.sqlite3"
)
target = sqlite3.connect(destination)
try:
source.backup(target)
if target.execute("PRAGMA quick_check").fetchone()[0] != "ok":
raise ReleaseUpdateError("release compensation backup failed integrity check")
finally:
target.close()
destination.chmod(0o600)
return destination
def load_compensation_marker(
connection: sqlite3.Connection,
release: str,
) -> dict[str, Any] | None:
row = connection.execute(
"SELECT value FROM metadata WHERE key = ?",
(compensation_key(release),),
).fetchone()
if row is None:
return None
return checked_marker(str(row[0]), release)
def apply_compensation(
database_path: Path,
backup_dir: Path,
release: str,
*,
now: datetime | None = None,
) -> tuple[dict[str, Any], str]:
applied_at = now or utc_now()
connection = sqlite3.connect(database_path, timeout=30)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA quick_check").fetchone()[0] != "ok":
raise ReleaseUpdateError("Edge SQLite integrity check failed")
existing = load_compensation_marker(connection, release)
if existing is not None:
return existing, "already_applied"
backup_path = backup_database(
connection,
backup_dir,
release,
applied_at,
)
connection.execute("BEGIN IMMEDIATE")
try:
existing = load_compensation_marker(connection, release)
if existing is not None:
connection.execute("COMMIT")
return existing, "already_applied"
rows = connection.execute(
"""
SELECT group_fingerprint, person_key, state_json
FROM player_accounts
ORDER BY group_fingerprint, person_key
"""
).fetchall()
groups = sorted({str(row["group_fingerprint"]) for row in rows})
old_balance_sum = 0
old_primogem_sum = 0
primogem_amount = primogem_compensation_amount(release)
timestamp = isoformat_utc(applied_at)
event_reason = f"{release} 版本更新补偿"
primogem_reason = f"{release} 版本更新补偿"
for row in rows:
try:
state = json.loads(str(row["state_json"]))
except json.JSONDecodeError as error:
raise ReleaseUpdateError("Mora account state is invalid") from error
if not isinstance(state, dict):
raise ReleaseUpdateError("Mora account state is invalid")
normalize_player_account(state)
balance = state.get("balance")
if (
type(balance) is not int
or not 0 <= balance <= MAX_BALANCE - COMPENSATION_AMOUNT
):
raise ReleaseUpdateError("Mora account balance is invalid")
old_balance_sum += balance
old_primogem_sum += int(state["primogems"])
state["balance"] = balance + COMPENSATION_AMOUNT
state["primogems"] += primogem_amount
append_coin_event(state, COMPENSATION_AMOUNT, event_reason)
state["coin_events"][-1]["created_at"] = applied_at.timestamp()
if primogem_amount:
append_primogem_event(
state,
primogem_amount,
primogem_reason,
)
state["primogem_events"][-1]["created_at"] = (
applied_at.timestamp()
)
connection.execute(
"""
UPDATE player_accounts
SET state_json = ?, updated_at = ?
WHERE group_fingerprint = ? AND person_key = ?
""",
(
json.dumps(state, ensure_ascii=False),
timestamp,
row["group_fingerprint"],
row["person_key"],
),
)
marker = {
"release": release,
"amount": COMPENSATION_AMOUNT,
"mora_amount": COMPENSATION_AMOUNT,
"primogem_amount": primogem_amount,
"accounts": len(rows),
"group_fingerprints": groups,
"old_balance_sum": old_balance_sum,
"new_balance_sum": (
old_balance_sum + COMPENSATION_AMOUNT * len(rows)
),
"old_primogem_sum": old_primogem_sum,
"new_primogem_sum": (
old_primogem_sum + primogem_amount * len(rows)
),
"applied_at": timestamp,
"backup": backup_path.name,
}
connection.execute(
"""
INSERT INTO metadata (key, value, updated_at)
VALUES (?, ?, ?)
""",
(
compensation_key(release),
json.dumps(marker, ensure_ascii=False, separators=(",", ":")),
timestamp,
),
)
connection.execute("COMMIT")
return marker, "applied"
except Exception:
if connection.in_transaction:
connection.execute("ROLLBACK")
raise
finally:
connection.close()
def metadata_value(
connection: sqlite3.Connection,
key: str,
) -> dict[str, Any] | None:
row = connection.execute(
"SELECT value FROM metadata WHERE key = ?",
(key,),
).fetchone()
if row is None:
return None
try:
value = json.loads(str(row[0]))
except json.JSONDecodeError as error:
raise ReleaseUpdateError("announcement marker is invalid") from error
if not isinstance(value, dict):
raise ReleaseUpdateError("announcement marker is invalid")
return value
def store_metadata(
connection: sqlite3.Connection,
key: str,
value: dict[str, Any],
updated_at: str,
) -> None:
connection.execute(
"""
INSERT INTO metadata (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE
SET value = excluded.value, updated_at = excluded.updated_at
""",
(
key,
json.dumps(value, ensure_ascii=False, separators=(",", ":")),
updated_at,
),
)
def run_delivery_helper(
delivery_helper: Path,
group_fingerprint: str,
announcement: str,
event_id: str,
) -> None:
payload = {
"group_fingerprint": group_fingerprint,
"reply": announcement,
"event_id": event_id,
}
try:
result = subprocess.run(
[sys.executable, str(delivery_helper)],
input=json.dumps(payload, ensure_ascii=False),
text=True,
capture_output=True,
timeout=70,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as error:
raise AnnouncementUncertainError("announcement transport failed") from error
if result.returncode != 0:
raise AnnouncementUncertainError(
f"announcement helper failed with return code {result.returncode}"
)
def deliver_announcements(
database_path: Path,
release: str,
groups: list[str],
announcement: str,
delivery_helper: Path,
*,
retry_uncertain: bool = False,
deliverer: Callable[[Path, str, str, str], None] = run_delivery_helper,
now: Callable[[], datetime] = utc_now,
) -> dict[str, int]:
delivered = 0
skipped = 0
for group in groups:
key = announcement_key(release, group)
group_hash = hashlib.sha256(group.encode()).hexdigest()[:12]
connection = sqlite3.connect(database_path, timeout=30)
try:
existing = metadata_value(connection, key)
state = str(existing.get("state", "")) if existing else ""
if state == "delivered":
skipped += 1
continue
if state in {"sending", "uncertain"} and not retry_uncertain:
raise AnnouncementUncertainError(
"previous announcement delivery is uncertain; "
"visually confirm before using --retry-uncertain-announcement"
)
attempt = int(existing.get("attempt", 0)) + 1 if existing else 1
started_at = isoformat_utc(now())
with connection:
store_metadata(
connection,
key,
{
"release": release,
"state": "sending",
"attempt": attempt,
"group_hash": group_hash,
"started_at": started_at,
},
started_at,
)
finally:
connection.close()
event_id = f"release-{release_slug(release)}-{group_hash}"
try:
deliverer(
delivery_helper,
group,
announcement,
event_id,
)
except Exception as error:
failed_at = isoformat_utc(now())
connection = sqlite3.connect(database_path, timeout=30)
try:
with connection:
store_metadata(
connection,
key,
{
"release": release,
"state": "uncertain",
"attempt": attempt,
"group_hash": group_hash,
"started_at": started_at,
"failed_at": failed_at,
"error_type": type(error).__name__[:80],
},
failed_at,
)
finally:
connection.close()
if isinstance(error, ReleaseUpdateError):
raise
raise AnnouncementUncertainError(
"announcement delivery is uncertain"
) from error
completed_at = isoformat_utc(now())
connection = sqlite3.connect(database_path, timeout=30)
try:
with connection:
store_metadata(
connection,
key,
{
"release": release,
"state": "delivered",
"attempt": attempt,
"group_hash": group_hash,
"started_at": started_at,
"delivered_at": completed_at,
},
completed_at,
)
finally:
connection.close()
delivered += 1
return {"delivered": delivered, "skipped": skipped}
def run_release_update(
*,
release_file: Path,
changelog: Path,
database_path: Path,
backup_dir: Path,
delivery_helper: Path,
retry_uncertain: bool = False,
deliverer: Callable[[Path, str, str, str], None] = run_delivery_helper,
applied_at: datetime | None = None,
clock: Callable[[], datetime] = utc_now,
) -> dict[str, Any]:
release = read_release(release_file)
expected_package_version = release_to_pep440(release)
if OFFICIAL_VERSION != expected_package_version:
raise ReleaseUpdateError(
"installed package version does not match VERSION"
)
summary = read_changelog_summary(changelog, release)
announcement = build_announcement(release, summary)
marker, compensation_state = apply_compensation(
database_path,
backup_dir,
release,
now=applied_at,
)
groups = [str(value) for value in marker["group_fingerprints"]]
announcement_result = deliver_announcements(
database_path,
release,
groups,
announcement,
delivery_helper,
retry_uncertain=retry_uncertain,
deliverer=deliverer,
now=clock,
)
connection = sqlite3.connect(
f"file:{database_path}?mode=ro",
uri=True,
)
try:
if connection.execute("PRAGMA quick_check").fetchone()[0] != "ok":
raise ReleaseUpdateError("post-release SQLite integrity check failed")
finally:
connection.close()
return {
"status": "ok",
"release": release,
"compensation": compensation_state,
"amount_each": COMPENSATION_AMOUNT,
"primogems_each": primogem_compensation_amount(release),
"accounts": int(marker["accounts"]),
"groups": len(groups),
"announcements_delivered": announcement_result["delivered"],
"announcements_skipped": announcement_result["skipped"],
"backup": str(marker["backup"]),
}
def run_release_lifecycle(
*,
release_file: Path,
database_path: Path,
delivery_helper: Path,
stage: str,
deliverer: Callable[[Path, str, str, str], None] = run_delivery_helper,
) -> dict[str, Any]:
if stage not in LIFECYCLE_STAGES:
raise ReleaseUpdateError(f"unsupported lifecycle stage: {stage}")
release = read_release(release_file)
message = build_lifecycle_message(release, stage)
groups = release_groups(database_path)
delivered = 0
for group in groups:
group_hash = hashlib.sha256(group.encode()).hexdigest()[:12]
event_id = f"release-{release_slug(release)}-{stage}-{group_hash}"
deliverer(delivery_helper, group, message, event_id)
delivered += 1
return {
"status": "ok",
"release": release,
"stage": stage,
"groups": len(groups),
"delivered": delivered,
}
def parse_args() -> argparse.Namespace:
home = Path.home()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--release-file",
type=Path,
default=ROOT / "VERSION",
)
parser.add_argument(
"--changelog",
type=Path,
default=ROOT / "CHANGELOG.md",
)
parser.add_argument(
"--database",
type=Path,
default=home / ".local/state/jwebot-edge/edge.sqlite3",
)
parser.add_argument(
"--backup-dir",
type=Path,
default=home / ".local/state/jwebot/admin-backups",
)
parser.add_argument(
"--delivery-helper",
type=Path,
default=ROOT / "jwebot_edge_delivery.py",
)
parser.add_argument(
"--retry-uncertain-announcement",
action="store_true",
help="retry only after visually confirming that the prior send did not land",
)
parser.add_argument(
"--lifecycle-stage",
choices=sorted(LIFECYCLE_STAGES),
help="send a guarded lifecycle notice instead of release settlement",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
if args.lifecycle_stage:
result = run_release_lifecycle(
release_file=args.release_file,
database_path=args.database,
delivery_helper=args.delivery_helper,
stage=args.lifecycle_stage,
)
else:
result = run_release_update(
release_file=args.release_file,
changelog=args.changelog,
database_path=args.database,
backup_dir=args.backup_dir,
delivery_helper=args.delivery_helper,
retry_uncertain=args.retry_uncertain_announcement,
)
except ReleaseUpdateError as error:
print(
json.dumps(
{
"status": "error",
"error_type": type(error).__name__,
"message": str(error),
},
ensure_ascii=False,
),
file=sys.stderr,
)
return 1
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())