mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:47:17 +01:00
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
178 lines
6.8 KiB
Python
Executable File
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())
|