#!/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 tool/preview_release.py testbot --seen 0.1.5 # what a group behind will get 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 dotenv import load_dotenv 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, seen: str | None = None) -> str: """Return the message a release at ``ordinal`` would post to a group. Keyed off the running version by default, because that is what every group is recorded at, so this is the delta they would actually be shown. ``seen`` overrides it with the version a group is actually recorded at, which is what makes this usable *after* a bump: three releases land in one commit series, every group is still at the version before them, and the message they will get is the three-block one this renders -- not the one-block delta from the build that is now running. """ if not 1 <= ordinal <= len(POPES): message = f"no pope with ordinal {ordinal}; the list has {len(POPES)}" raise PreviewError(message) floor = __version__ if seen is None else seen pending = since(load(), floor) if not pending: message = ( f"DOCS-changelog.md has nothing newer than {floor}; " 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, seen: str | None = None ) -> 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, seen) 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( "--seen", default=None, help="the version the group is recorded at (default: this build's), which is " "how to preview a run of releases after they have already been bumped", ) parser.add_argument("--print-only", action="store_true", help="render it, send nothing") args = parser.parse_args() # Before the SELFTEST_GROUPS check, not after: load_config() would load # the .env eventually, but the fail-closed gate reads os.environ first, # so without this the documented invocation refused every group unless # the variable had been exported into the shell by hand. load_dotenv() 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, seen=args.seen)) 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())