Release beta 1.0.4 update settlement
check / local-gate (push) Failing after 1m7s

This commit is contained in:
JWeBot Contributors
2026-10-16 16:10:19 +08:00
parent bf6b738baa
commit 127fea8cd0
24 changed files with 954 additions and 28 deletions
+16
View File
@@ -24,6 +24,22 @@ These rules are mandatory for every automated change in this repository.
or adaptive restart-stability gates.
- If a deployment gate fails, verify rollback state before retrying.
## Mandatory Release Settlement
- Every successful production update must run `jwebot_release_update.py`
after cutover gates pass and before the release is declared complete.
- Each release grants exactly 200 Mora to every account that exists when the
settlement starts. The grant and account movement are idempotent by version.
- The first bullet in each release's `CHANGELOG.md` section must be one concise,
user-facing sentence suitable for the group update announcement.
- After crediting accounts, send the version, first changelog bullet, and
`Mora x200` compensation notice through the guarded group sender.
- Never blindly retry an uncertain announcement. Verify the group visually,
then use `--retry-uncertain-announcement` only when the prior message did not
land.
- A production release is incomplete until compensation, announcement,
SQLite integrity, empty queues, Edge health, and service stability pass.
## Configuration And Access
- Real profiles live under `~/.config/jwebot/`, never in the repository.
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## beta 1.0.4.1016 - 2026-10-16
- 新增版本更新补偿流程:每次生产更新后向所有既有账户发放 200 摩拉,并在目标群发布更新公告。
- Add a version-idempotent post-release settlement tool that creates an online
SQLite backup, grants Mora through the production account schema, records a
version-labelled movement, and tracks announcement delivery independently.
- Run release settlement only after coordinated cutover and rollback gates are
disarmed, keeping a healthy runtime active when GUI delivery is uncertain.
- Require the first changelog bullet to be a concise user-facing announcement
summary and require visual confirmation before explicitly retrying an
uncertain announcement.
## beta 1.0.3.1016 - 2026-10-16
- Rename the virtual currency user-facing copy to Mora (`摩拉`) while keeping
+8 -4
View File
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。
当前版本:`beta 1.0.3.1016`,更新日期:2026-10-16。
当前版本:`beta 1.0.4.1016`,更新日期:2026-10-16。
1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、
Steam 状态与时长查询、虚拟摩拉游戏、多人彩票、21 点、CS Major
@@ -189,7 +189,9 @@ sudo ./deploy/scripts/deploy-jwebot-production.sh /path/to/JWeBot jwebot
```
部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应
录制服务重启稳定性检查。旧 OCR 服务保留安装,但保持禁用。
录制服务重启稳定性检查。切换成功后,部署器按版本幂等地向每个既有账户
发放 200 摩拉、写入账户流水,并使用当前 changelog 的第一条摘要发布群公告。
旧 OCR 服务保留安装,但保持禁用。
### 仓库结构
@@ -263,7 +265,7 @@ visually confirmed GUI bridge.
Current version:
```text
beta 1.0.3.1016
beta 1.0.4.1016
```
Updated: 2026-10-16.
@@ -452,7 +454,9 @@ sudo ./deploy/scripts/deploy-jwebot-production.sh /path/to/JWeBot jwebot
The deployer refuses cutover unless the ready-window, empty-queue, Edge health,
SQLite integrity, and adaptive restart-stability gates pass. Legacy OCR units
remain installed but disabled.
remain installed but disabled. After cutover, version-idempotent release
settlement grants 200 Mora to every existing account, records the movement,
and sends the first changelog bullet as the guarded group announcement.
## Repository Map
+1 -1
View File
@@ -1 +1 @@
beta 1.0.3.1016
beta 1.0.4.1016
+15 -1
View File
@@ -421,6 +421,7 @@ test -x "${runtime_python}"
for path in \
"${source_dir}/wechat_local_ocr.py" \
"${source_dir}/wechat-gui-bridge" \
"${source_dir}/jwebot_release_update.py" \
"${source_dir}/deploy/scripts/install-wechat-health-watch.sh" \
"${source_dir}/deploy/systemd/system/wechat-local-ocr-recorder.service" \
"${source_dir}/deploy/systemd/system/wechat-local-ocr-responder.service" \
@@ -430,7 +431,9 @@ do
done
bash -n "${source_dir}/wechat-gui-bridge"
sh -n "${source_dir}/deploy/scripts/install-wechat-health-watch.sh"
"${runtime_python}" -m py_compile "${source_dir}/wechat_local_ocr.py"
"${runtime_python}" -m py_compile \
"${source_dir}/wechat_local_ocr.py" \
"${source_dir}/jwebot_release_update.py"
preflight_rendered_dir=$(mktemp -d)
render_root_configuration "${preflight_rendered_dir}"
systemd-analyze verify \
@@ -619,6 +622,17 @@ success=1
armed=0
trap - EXIT HUP INT TERM
jwebot_log "release settlement starting"
release_update_output=$(
/usr/sbin/runuser -u "${target_user}" -- /usr/bin/env \
HOME="${target_home}" \
XDG_RUNTIME_DIR="${user_runtime}" \
DBUS_SESSION_BUS_ADDRESS="unix:path=${user_runtime}/bus" \
"${runtime_python}" "${target_runtime}/jwebot_release_update.py"
)
printf '%s\n' "${release_update_output}"
jwebot_log "release settlement ready"
jwebot_log "deployment ready version=$(cat "${source_dir}/VERSION")"
jwebot_log "backup active path=${backup_dir}"
jwebot_log "legacy OCR inactive"
+10 -2
View File
@@ -27,6 +27,7 @@ adaptive_script_name=wechat_adaptive_recorder.py
adaptive_bridge_name=wechat_adaptive_gui_bridge.py
edge_script_name=jwebot_edge.py
edge_delivery_name=jwebot_edge_delivery.py
release_update_name=jwebot_release_update.py
service_name=wechat-health-watch.service
timer_name=wechat-health-watch.timer
adaptive_service_name=wechat-ocr-adaptive-recorder.service
@@ -209,6 +210,7 @@ rollback() {
restore_file "$adaptive_bridge_name" "$target_dir/$adaptive_bridge_name" 0700
restore_file "$edge_script_name" "$target_dir/$edge_script_name" 0700
restore_file "$edge_delivery_name" "$target_dir/$edge_delivery_name" 0700
restore_file "$release_update_name" "$target_dir/$release_update_name" 0700
for package_name in \
jwebot_config \
jwebot_sdk \
@@ -246,6 +248,7 @@ for path in \
"$runtime_source_dir/$adaptive_bridge_name" \
"$runtime_source_dir/$edge_script_name" \
"$runtime_source_dir/$edge_delivery_name" \
"$runtime_source_dir/$release_update_name" \
"$user_unit_source_dir/$service_name" \
"$user_unit_source_dir/$timer_name" \
"$user_unit_source_dir/$adaptive_service_name" \
@@ -289,7 +292,8 @@ for name in \
"$adaptive_script_name" \
"$adaptive_bridge_name" \
"$edge_script_name" \
"$edge_delivery_name"
"$edge_delivery_name" \
"$release_update_name"
do
if [ -f "$target_dir/$name" ]; then
cp -a "$target_dir/$name" "$backup_dir/$name"
@@ -342,6 +346,9 @@ install -m 0700 "$runtime_source_dir/$adaptive_script_name" "$target_dir/$adapti
install -m 0700 "$runtime_source_dir/$adaptive_bridge_name" "$target_dir/$adaptive_bridge_name"
install -m 0700 "$runtime_source_dir/$edge_script_name" "$target_dir/$edge_script_name"
install -m 0700 "$runtime_source_dir/$edge_delivery_name" "$target_dir/$edge_delivery_name"
install -m 0700 \
"$runtime_source_dir/$release_update_name" \
"$target_dir/$release_update_name"
rm -rf \
"$target_dir/jwebot_config" \
"$target_dir/jwebot_sdk" \
@@ -395,7 +402,8 @@ for name in \
"$adaptive_script_name" \
"$adaptive_bridge_name" \
"$edge_script_name" \
"$edge_delivery_name"
"$edge_delivery_name" \
"$release_update_name"
do
"$runtime_python" -m py_compile "$target_dir/$name"
done
+23
View File
@@ -18,6 +18,9 @@ exception.
- every package `__version__`;
- `README.md`;
- `CHANGELOG.md`.
- The first bullet under each release heading in `CHANGELOG.md` must be one
concise, user-facing sentence. It is the canonical group announcement
summary.
- Do not mark production complete merely because code was committed. Production
is complete only after staging, cutover, acceptance, observation, and backup
restore checks pass.
@@ -63,6 +66,26 @@ Before touching production runtime files on production host:
SQLite gates.
- If any gate fails, confirm rollback state before retrying.
## Mandatory Post-Release Settlement
- After cutover and rollback gates pass, run the installed
`jwebot_release_update.py`.
- The settlement grants exactly 200 Mora to every account that exists at the
start of that release settlement and appends a version-labelled account
movement.
- Compensation uses the full release string as its idempotency key. Rerunning
the same release must not grant Mora twice.
- The tool sends one guarded announcement containing the version, the first
changelog bullet, and the `Mora x200` compensation notice.
- Announcement state is tracked separately from compensation. A missing or
uncertain GUI receipt must never roll back a healthy runtime or trigger
another grant.
- If delivery is uncertain, inspect the configured group before using
`--retry-uncertain-announcement`; never retry blindly.
- Production is not complete until the settlement reports success, SQLite
`quick_check` is `ok`, queues are empty, and service restart counts remain
stable.
## Backup And Restore
- Pre-deploy and post-deploy baselines must include runtime files, systemd
+28 -1
View File
@@ -73,7 +73,12 @@ The deployer prints an eight-stage progress bar and:
- leaves legacy OCR disabled;
- verifies Edge health and adaptive restart stability;
- writes and verifies backup SHA-256 checksums;
- rolls back files, state, and unit topology on failure.
- rolls back files, state, and unit topology on cutover failure;
- after rollback is disarmed, grants each existing account 200 Mora, records a
version-labelled movement, and sends the guarded group update announcement.
The first bullet in the current `CHANGELOG.md` release section is the canonical
one-sentence announcement summary. Keep it concise and user-facing.
## Post-Cutover
@@ -104,6 +109,28 @@ PY
Player-visible acceptance must use deliberate synthetic messages in the
configured group and confirm exactly one ACK and one final response.
The deployer also runs the installed release settlement tool. Its successful
JSON result must report the expected account count and announcement delivery.
The operation is idempotent by the full release string, so rerunning the same
command never grants Mora twice:
```sh
/opt/jwebot/venv/bin/python \
~/.local/lib/wechat-health-watch/jwebot_release_update.py
```
If it reports `AnnouncementUncertainError`, inspect the configured group first.
Only when the announcement is confirmed absent may the operator retry:
```sh
/opt/jwebot/venv/bin/python \
~/.local/lib/wechat-health-watch/jwebot_release_update.py \
--retry-uncertain-announcement
```
Do not repeat the full deployment to repair an uncertain announcement. The
runtime cutover and version-keyed compensation may already be complete.
## Rollback
If any gate fails:
+599
View File
@@ -0,0 +1,599 @@
#!/usr/bin/env python3
"""Apply the mandatory post-release Mora grant and group 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,
normalize_player_account,
)
COMPENSATION_AMOUNT = 200
MAX_BALANCE = 1_000_000_000
MAX_SUMMARY_CHARS = 300
MAX_ANNOUNCEMENT_CHARS = 900
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*$"
)
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:
announcement = (
"亲爱的群友:\n"
f"{release} 更新现已完成。\n"
f"更新内容:{summary}\n"
f"更新补偿:摩拉 x{COMPENSATION_AMOUNT}\n"
"补偿范围:截至本公告发布前已创建摩拉账户的玩家\n"
"发放方式:补偿已直接到账,无需领取\n"
"感谢大家对 Taffy 的支持与理解,祝大家游戏愉快!"
)
if len(announcement) > MAX_ANNOUNCEMENT_CHARS:
raise ReleaseUpdateError("release announcement is too long")
return announcement
def compensation_key(release: str) -> str:
return f"release_compensation:{release}"
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 checked_marker(raw: str, release: str) -> dict[str, Any]:
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 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
timestamp = isoformat_utc(applied_at)
event_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
state["balance"] = balance + COMPENSATION_AMOUNT
append_coin_event(state, COMPENSATION_AMOUNT, event_reason)
state["coin_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,
"accounts": len(rows),
"group_fingerprints": groups,
"old_balance_sum": old_balance_sum,
"new_balance_sum": (
old_balance_sum + COMPENSATION_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,
"accounts": int(marker["accounts"]),
"groups": len(groups),
"announcements_delivered": announcement_result["delivered"],
"announcements_skipped": announcement_result["skipped"],
"backup": str(marker["backup"]),
}
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",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
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())
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-config"
version = "1.0.3b1+1016"
version = "1.0.4b1+1016"
description = "Profile-based configuration compiler for JWeBot"
requires-python = ">=3.9"
dependencies = [
@@ -36,4 +36,4 @@ __all__ = [
"write_default_profile",
]
__version__ = "1.0.3b1+1016"
__version__ = "1.0.4b1+1016"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-extension-host"
version = "1.0.3b1+1016"
version = "1.0.4b1+1016"
description = "Single-release, single-invocation process host for JWeBot extensions"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==1.0.3b1+1016",
"jwebot-kernel==1.0.3b1+1016",
"jwebot-sdk==1.0.4b1+1016",
"jwebot-kernel==1.0.4b1+1016",
]
[project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request",
]
__version__ = "1.0.3b1+1016"
__version__ = "1.0.4b1+1016"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-kernel"
version = "1.0.3b1+1016"
version = "1.0.4b1+1016"
description = "Stable routing and extension authority for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-config==1.0.3b1+1016",
"jwebot-sdk==1.0.3b1+1016",
"jwebot-official-extensions==1.0.3b1+1016",
"jwebot-config==1.0.4b1+1016",
"jwebot-sdk==1.0.4b1+1016",
"jwebot-official-extensions==1.0.4b1+1016",
"tomli>=2; python_version < '3.11'",
]
@@ -107,4 +107,4 @@ __all__ = [
"verify_installed_release_signature",
]
__version__ = "1.0.3b1+1016"
__version__ = "1.0.4b1+1016"
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-official-extensions"
version = "1.0.3b1+1016"
version = "1.0.4b1+1016"
description = "Reviewed local command adapters and broker implementation for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==1.0.3b1+1016",
"jwebot-sdk==1.0.4b1+1016",
]
[tool.setuptools.packages.find]
@@ -90,4 +90,4 @@ __all__ = [
"render_feature_list",
]
__version__ = "1.0.3b1+1016"
__version__ = "1.0.4b1+1016"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-sdk"
version = "1.0.3b1+1016"
version = "1.0.4b1+1016"
description = "Typed, capability-limited extension API for JWeBot plugins"
requires-python = ">=3.9"
dependencies = []
@@ -38,4 +38,4 @@ __all__ = [
"SingleGroupContext",
]
__version__ = "1.0.3b1+1016"
__version__ = "1.0.4b1+1016"
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot]
release = "beta 1.0.3.1016"
release = "beta 1.0.4.1016"
production_deployments_enabled_by_default = false
[tool.jwebot.workspace]
+12
View File
@@ -58,6 +58,7 @@ class DeploymentScriptTests(unittest.TestCase):
"wechat_adaptive_gui_bridge.py",
"jwebot_edge.py",
"jwebot_edge_delivery.py",
"jwebot_release_update.py",
"jwebot_config",
"jwebot_sdk",
"jwebot_kernel",
@@ -126,6 +127,9 @@ class DeploymentScriptTests(unittest.TestCase):
'jwebot_log "runtime install ready"',
'jwebot_log "edge active"',
'jwebot_log "adaptive active"',
'jwebot_log "release settlement starting"',
'"${target_runtime}/jwebot_release_update.py"',
'jwebot_log "release settlement ready"',
'jwebot_log "deployment ready version=',
'cd "${target_home}"',
):
@@ -138,6 +142,14 @@ class DeploymentScriptTests(unittest.TestCase):
source.count("xdotool"),
source.count("/usr/bin/xdotool"),
)
self.assertLess(
source.index("trap - EXIT HUP INT TERM"),
source.index('jwebot_log "release settlement starting"'),
)
self.assertLess(
source.index('jwebot_log "release settlement ready"'),
source.index('jwebot_log "deployment ready version='),
)
def test_production_reports_bounded_progress(self):
source = PRODUCTION_INSTALLER.read_text(encoding="utf-8")
+2 -2
View File
@@ -884,9 +884,9 @@ class LedgerAndApplicationTests(unittest.TestCase):
self.assertEqual(version["route"], "local")
self.assertIn("JWeBot 版本", version["text"])
self.assertIn("kernel", version["text"])
self.assertIn("最新版本:beta 1.0.3.1016", version["text"])
self.assertIn("最新版本:beta 1.0.4.1016", version["text"])
self.assertIn("更新时间:2026-10-16", version["text"])
self.assertIn("更新摘要:Rename the virtual currency", version["text"])
self.assertIn("更新摘要:新增版本更新补偿流程", version["text"])
self.assertEqual(debug["route"], "local")
self.assertIn("调试埋点", debug["text"])
self.assertEqual(balance["route"], "local")
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
import importlib.util
import json
import sqlite3
import sys
import tempfile
import unittest
from datetime import datetime, timezone
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
MODULE_PATH = PROJECT_ROOT / "jwebot_release_update.py"
SPEC = importlib.util.spec_from_file_location("jwebot_release_update", MODULE_PATH)
MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
class ReleaseUpdateTests(unittest.TestCase):
release = "beta 1.0.4.1016"
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
self.database = self.root / "edge.sqlite3"
self.backups = self.root / "backups"
self.version = self.root / "VERSION"
self.changelog = self.root / "CHANGELOG.md"
self.delivery_helper = self.root / "jwebot_edge_delivery.py"
self.version.write_text(self.release + "\n", encoding="utf-8")
self.changelog.write_text(
(
"# Changelog\n\n"
f"## {self.release} - 2026-10-16\n\n"
"- 更新摩拉账户并修复重复回复问题。\n"
"- Internal implementation detail.\n"
),
encoding="utf-8",
)
self.delivery_helper.write_text("# test helper\n", encoding="utf-8")
connection = sqlite3.connect(self.database)
connection.executescript(
"""
CREATE TABLE metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE player_accounts (
group_fingerprint TEXT NOT NULL,
person_key TEXT NOT NULL,
state_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, person_key)
);
"""
)
for person_key, balance in (("person-a", 1000), ("person-b", 50)):
connection.execute(
"INSERT INTO player_accounts VALUES (?, ?, ?, ?)",
(
"group-a",
person_key,
json.dumps({"balance": balance}),
"2026-10-16T00:00:00Z",
),
)
connection.commit()
connection.close()
def tearDown(self):
self.temporary.cleanup()
def account_states(self):
connection = sqlite3.connect(self.database)
try:
rows = connection.execute(
"SELECT state_json FROM player_accounts ORDER BY person_key"
).fetchall()
finally:
connection.close()
return [json.loads(row[0]) for row in rows]
def announcement_marker(self):
key = MODULE.announcement_key(self.release, "group-a")
connection = sqlite3.connect(self.database)
try:
row = connection.execute(
"SELECT value FROM metadata WHERE key = ?",
(key,),
).fetchone()
finally:
connection.close()
return json.loads(row[0]) if row else None
def test_changelog_first_bullet_is_the_announcement_summary(self):
self.changelog.write_text(
(
"# Changelog\n\n"
f"## {self.release} - 2026-10-16\n\n"
"- 第一行更新摘要,\n"
" 同一条摘要可以换行。\n"
"- 第二条不进入群公告。\n"
),
encoding="utf-8",
)
summary = MODULE.read_changelog_summary(self.changelog, self.release)
self.assertEqual(summary, "第一行更新摘要, 同一条摘要可以换行。")
announcement = MODULE.build_announcement(self.release, summary)
self.assertIn(f"{self.release} 更新现已完成", announcement)
self.assertIn("更新补偿:摩拉 x200", announcement)
self.assertNotIn("第二条", announcement)
def test_release_update_grants_once_and_announces_once(self):
delivered = []
def deliverer(path, group, announcement, event_id):
delivered.append((path, group, announcement, event_id))
applied_at = datetime(2026, 10, 16, 8, 0, tzinfo=timezone.utc)
first = MODULE.run_release_update(
release_file=self.version,
changelog=self.changelog,
database_path=self.database,
backup_dir=self.backups,
delivery_helper=self.delivery_helper,
deliverer=deliverer,
applied_at=applied_at,
)
second = MODULE.run_release_update(
release_file=self.version,
changelog=self.changelog,
database_path=self.database,
backup_dir=self.backups,
delivery_helper=self.delivery_helper,
deliverer=deliverer,
applied_at=applied_at,
)
self.assertEqual(first["compensation"], "applied")
self.assertEqual(second["compensation"], "already_applied")
self.assertEqual(first["accounts"], 2)
self.assertEqual(len(delivered), 1)
self.assertEqual(delivered[0][1], "group-a")
self.assertIn("更新摩拉账户并修复重复回复问题", delivered[0][2])
self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250])
for state in self.account_states():
matches = [
event
for event in state["coin_events"]
if event["delta"] == 200
and event["reason"] == f"{self.release} 版本更新补偿"
]
self.assertEqual(len(matches), 1)
self.assertEqual(matches[0]["created_at"], applied_at.timestamp())
self.assertEqual(self.announcement_marker()["state"], "delivered")
self.assertEqual(len(list(self.backups.glob("*.sqlite3"))), 1)
def test_uncertain_announcement_never_duplicates_compensation(self):
def uncertain(*_args):
raise MODULE.AnnouncementUncertainError("uncertain")
with self.assertRaises(MODULE.AnnouncementUncertainError):
MODULE.run_release_update(
release_file=self.version,
changelog=self.changelog,
database_path=self.database,
backup_dir=self.backups,
delivery_helper=self.delivery_helper,
deliverer=uncertain,
)
self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250])
self.assertEqual(self.announcement_marker()["state"], "uncertain")
called = []
def delivered(*args):
called.append(args)
with self.assertRaises(MODULE.AnnouncementUncertainError):
MODULE.run_release_update(
release_file=self.version,
changelog=self.changelog,
database_path=self.database,
backup_dir=self.backups,
delivery_helper=self.delivery_helper,
deliverer=delivered,
)
self.assertFalse(called)
result = MODULE.run_release_update(
release_file=self.version,
changelog=self.changelog,
database_path=self.database,
backup_dir=self.backups,
delivery_helper=self.delivery_helper,
deliverer=delivered,
retry_uncertain=True,
)
self.assertEqual(result["compensation"], "already_applied")
self.assertEqual(len(called), 1)
self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250])
self.assertEqual(self.announcement_marker()["state"], "delivered")
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -11,6 +11,7 @@ cd "$(dirname "$0")/.."
python3 -m py_compile \
jwebot_edge.py \
jwebot_edge_delivery.py \
jwebot_release_update.py \
wechat_local_ocr.py \
wechat_adaptive_recorder.py \
wechat_adaptive_gui_bridge.py \