#!/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.=` 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())