713 lines
23 KiB
Python
713 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,
|
|
}
|
|
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())
|