signal-bot/tool/preview_release.py
2026-08-30 21:43:33 +02:00

156 lines
5.7 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Post the *next* release's announcement into a test group, without releasing.
DOCS-changelog.md says a version bump is a publish, not a checkpoint: bumping
RELEASE_ORDINAL and restarting posts into every watched group at once,
including the real one. That leaves the announcement itself as the one feature
which could never be exercised before it shipped -- and it is now the feature
carrying a portrait, a Latin name and a link, so "it renders fine" is no longer
a safe assumption.
This sends exactly the bytes the real announcement would send, to one named
test group, and touches nothing else: RELEASE_ORDINAL is not bumped, no
group_meta row is written or read, and every other group hears nothing. Run it,
look at Signal, then bump for real.
tool/preview_release.py testbot
tool/preview_release.py testbot --ordinal 20 --print-only
Fail-closed the same way scripts/selftest.sh is: the group must be named in
SELFTEST_GROUPS, so this cannot post a release that does not exist into the
group that would believe it.
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from typing import Final
import httpx
from signal_bot.announce import render
from signal_bot.app import HTTP_TIMEOUT_SECONDS, USER_AGENT
from signal_bot.changelog import load, since
from signal_bot.config_env import load_config
from signal_bot.http_client import ImageFetcher
from signal_bot.portraits import PortraitCache
from signal_bot.selftest import allowed_groups
from signal_bot.transport_rest import RestTransport
from signal_bot.versioning import (
POPES,
RELEASE_ORDINAL,
__version__,
latin_name,
pope_for,
version_for,
)
EXIT_REFUSED: Final = 2
class PreviewError(RuntimeError):
"""Raised when the preview cannot be sent as asked."""
async def group_id_for(client: httpx.AsyncClient, groups_url: str, name: str) -> str:
"""Return the `group.<base64>=` id the transport addresses a group by.
Not internal_id, which is what selftest.resolve_group wants: that one goes
into an inbound envelope, this one goes into an outbound send.
"""
response = await client.get(groups_url)
response.raise_for_status()
for entry in response.json():
if entry.get("name") == name:
group_id = str(entry.get("id") or "")
if not group_id:
message = f"group {name!r} has no addressable id"
raise PreviewError(message)
return group_id
message = f"no group named {name!r}"
raise PreviewError(message)
def announcement_for(ordinal: int) -> str:
"""Return the message a release at ``ordinal`` would post, for a group here.
Keyed off the running version, because that is what every group is
recorded at, so this is the delta they would actually be shown.
"""
if not 1 <= ordinal <= len(POPES):
message = f"no pope with ordinal {ordinal}; the list has {len(POPES)}"
raise PreviewError(message)
pending = since(load(), __version__)
if not pending:
message = (
f"DOCS-changelog.md has nothing newer than {__version__}; "
f"add the ## {version_for(ordinal)} section first"
)
raise PreviewError(message)
return render(pope_for(ordinal), pending)
async def send_preview(group: str, ordinal: int, *, print_only: bool) -> None:
"""Render the announcement and, unless asked not to, post it."""
permitted = allowed_groups(os.environ)
if group not in permitted:
listed = ", ".join(sorted(permitted)) or "(none)"
message = f"{group!r} is not in SELFTEST_GROUPS; allowed: {listed}"
raise PreviewError(message)
pope = pope_for(ordinal)
text = announcement_for(ordinal)
sys.stdout.write(f"== {version_for(ordinal)} «{latin_name(pope)}» -> {group}\n")
sys.stdout.write(f"== caption: {len(text)} chars, {len(text.encode())} bytes\n")
sys.stdout.write(f"{text}\n")
if print_only:
sys.stdout.write("== not sent (--print-only)\n")
return
sys.stdout.flush()
config = load_config()
# The app's client, headers and all: Wikimedia answers 403 to httpx's
# default User-Agent, so a bare client here would fail exactly where the
# bot succeeds -- which is the opposite of what a preview is for.
async with httpx.AsyncClient(
timeout=HTTP_TIMEOUT_SECONDS,
headers={"User-Agent": USER_AGENT},
) as client:
group_id = await group_id_for(client, config.groups_url, group)
portraits = PortraitCache(ImageFetcher(client), config.commons_api_url)
image = await portraits.get_base64(pope)
await RestTransport(config, client).send_image(image, group_id, caption=text)
sys.stdout.write(f"== sent to {group}; look at it in Signal\n")
def main() -> int:
"""Parse arguments and run one preview."""
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("group", help="test group name; must be in SELFTEST_GROUPS")
parser.add_argument(
"--ordinal",
type=int,
default=None,
help="papal ordinal to preview (default: the next one after this build)",
)
parser.add_argument("--print-only", action="store_true", help="render it, send nothing")
args = parser.parse_args()
ordinal = RELEASE_ORDINAL + 1 if args.ordinal is None else args.ordinal
try:
asyncio.run(send_preview(args.group, ordinal, print_only=args.print_only))
except (PreviewError, httpx.HTTPError) as error:
sys.stderr.write(f"preview_release: {error}\n")
return EXIT_REFUSED
return 0
if __name__ == "__main__":
raise SystemExit(main())