signal-bot/tool/preview_release.py
Krzysztof kuhy Rudnicki 1f4080e75d fix: the release preview can be run, and can preview a group's real delta
Two things it could not do, both found trying to preview this release.

It read SELFTEST_GROUPS off os.environ before anything had loaded .env, so
its own documented invocation refused every group unless the variable had
been exported by hand. Fail-closed, so it never posted anywhere wrong -- it
simply never posted at all.

And it always rendered the delta from the *running* build, which is empty
once the bump is committed. Three releases landing together are still one
announcement to a group recorded three versions back, so --seen renders what
that group will actually be shown rather than what the current build would
add.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
2026-08-30 22:37:22 +02:00

178 lines
6.8 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
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.<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, 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())