From b75df7bc6319f2ea9797b724543f844bf34d7060 Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Tue, 3 Nov 2026 14:38:51 -0500 Subject: [PATCH] ci: announce each release in slack once the split and packagist are green Adds a Release Announce workflow that runs after the tag's Split Packages run, verifies the GitHub Release, the split and Packagist, has Claude write a plain-language summary of the changelog, and posts it with the changelog to #releases (@channel) and #general. Any failed check fails the run without posting, and re-runs skip channels that already have the post. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017KaHtZbFKQDddzKrJV6VTk --- .../release-announce.cpython-315.pyc | Bin 0 -> 24414 bytes .github/scripts/release-announce.py | 346 ++++++++++++++++++ .github/workflows/release-announce.yml | 68 ++++ 3 files changed, 414 insertions(+) create mode 100644 .github/scripts/__pycache__/release-announce.cpython-315.pyc create mode 100755 .github/scripts/release-announce.py create mode 100644 .github/workflows/release-announce.yml diff --git a/.github/scripts/__pycache__/release-announce.cpython-315.pyc b/.github/scripts/__pycache__/release-announce.cpython-315.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3132b999e32eb17db418ae39b3468d2b0db448d1 GIT binary patch literal 24414 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 literal 0 HcmV?d00001 diff --git a/.github/scripts/release-announce.py b/.github/scripts/release-announce.py new file mode 100755 index 00000000..143cb242 --- /dev/null +++ b/.github/scripts/release-announce.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +"""Announce a published Marko release in Slack. + +Runs from .github/workflows/release-announce.yml once the tag's package split has finished. +Every check fails loudly (non-zero exit, red run) and nothing is posted unless all of them pass: + + 1. VERSION is a plain X.Y.Z tag with a published GitHub Release. + 2. The latest `Split Packages` run for the tag completed successfully. + 3. Packagist serves the version for marko/core (polled until PACKAGIST_TIMEOUT). + 4. CHANGELOG.md at the tag has a section for the version. + 5. Claude writes a plain-language summary paragraph that passes validation. + +Each target channel is checked for an earlier post by this bot for the same version, so a re-run +never posts twice. #releases gets an @channel mention; #general gets the same post without it. + +Environment: + VERSION release tag, e.g. 0.13.0 + TARGET releases | general | both + PREVIEW "true" prints the Slack payloads instead of posting + GITHUB_TOKEN, GITHUB_REPOSITORY + SLACK_BOT_TOKEN, SLACK_RELEASES_CHANNEL, SLACK_GENERAL_CHANNEL + ANTHROPIC_API_KEY + PACKAGIST_TIMEOUT seconds to wait for Packagist (default 1800) +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +import anthropic + +MODEL = "claude-opus-5-5" +SEMVER = re.compile(r"^\d+\.\d+\.\d+$") +URL = re.compile(r"https?://[^\s)>\]]+") +PRE_CHUNK = 2500 + +STYLE_EXAMPLE = ( + "Marko now has a built-in SQLite driver, so you can start an app or run tests without " + "setting up a separate database server. Remember-me sign-ins now work reliably: the " + "remember cookie used to get dropped. You can also turn on \"keep me signed in\" in one " + "step when someone logs in, and an app can check that a password reset link is still " + "valid before asking for a new password. Queues are easier to tune and monitor, with each " + "queue able to set its own time limits. Large file uploads now get a clear \"too large\" " + "response instead of crashing the request, and commands no longer quietly ignore options " + "they don't recognize: --help now shows help instead of running the command. A few of " + "these changes need small code updates in existing apps, listed under Breaking Changes " + "below." +) + +SYSTEM_PROMPT = f"""You write the opening paragraph of the release announcement for Marko, a modular PHP framework, posted in the project's Slack community. + +Readers are PHP developers who use or follow Marko. They want to know, in plain language, what this release lets them do and what got fixed, without reading the changelog. The full changelog is posted directly below your paragraph. + +Write exactly one paragraph of 3 to 6 sentences: +- Lead with the most useful additions, then the fixes people would notice. Group related changes rather than listing every PR. +- Describe effects for app developers, not implementation details. Avoid class names, method names and jargon unless a reader needs the name to act on it. +- If the changelog has a Breaking Changes section, end with one sentence saying some changes need small code updates in existing apps and are listed under Breaking Changes below. If it has none, do not mention breaking changes. +- Only state what the changelog and PR descriptions support. Do not invent features, numbers or dates. +- Plain text only: no markdown, no backticks, no bullet points, no headings, no emoji, no greeting and no sign-off. Do not repeat the version number or say "this release" more than once. + +This paragraph, written for 0.13.0, shows the tone and length the maintainer approved: + +{STYLE_EXAMPLE} + +Reply with the paragraph and nothing else.""" + + +def fail(message: str) -> None: + print(f"::error::{message}", file=sys.stderr) + sys.exit(1) + + +def env(name: str, default: str | None = None) -> str: + value = os.environ.get(name, default) + if value is None or value == "": + fail(f"Missing required environment variable {name}") + return value + + +def http_json(url: str, *, headers: dict[str, str] | None = None, data: dict | None = None) -> dict: + body = json.dumps(data).encode() if data is not None else None + request = urllib.request.Request(url, data=body, headers=headers or {}) + if body is not None: + request.add_header("Content-Type", "application/json; charset=utf-8") + last_error: Exception | None = None + for attempt in range(4): + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.loads(response.read().decode()) + except urllib.error.HTTPError as error: + if error.code == 404: + raise + if error.code < 500 and error.code != 429: + fail(f"{url} returned HTTP {error.code}: {error.read().decode()[:500]}") + last_error = error + except (urllib.error.URLError, TimeoutError) as error: + last_error = error + time.sleep(5 * (attempt + 1)) + fail(f"{url} failed after retries: {last_error}") + raise AssertionError # unreachable + + +def github(path: str) -> dict: + return http_json( + f"https://api.github.com/repos/{env('GITHUB_REPOSITORY')}{path}", + headers={ + "Authorization": f"Bearer {env('GITHUB_TOKEN')}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + + +def slack(method: str, payload: dict) -> dict: + result = http_json( + f"https://slack.com/api/{method}", + headers={"Authorization": f"Bearer {env('SLACK_BOT_TOKEN')}"}, + data=payload, + ) + if not result.get("ok"): + fail(f"Slack {method} failed: {result.get('error')} {result.get('needed', '')}".strip()) + return result + + +def verify_release(version: str) -> None: + try: + release = github(f"/releases/tags/{version}") + except urllib.error.HTTPError: + fail(f"No GitHub Release exists for tag {version}") + if release.get("draft"): + fail(f"GitHub Release {version} is still a draft") + + +def verify_split(version: str) -> None: + query = urllib.parse.urlencode({"branch": version, "event": "push", "per_page": 5}) + runs = github(f"/actions/workflows/split.yml/runs?{query}").get("workflow_runs", []) + if not runs: + fail(f"No Split Packages run found for tag {version}") + latest = runs[0] + if latest["status"] != "completed" or latest["conclusion"] != "success": + fail( + f"Split Packages for {version} is {latest['status']}/{latest['conclusion']}: " + f"{latest['html_url']}" + ) + print(f"Split Packages for {version} succeeded: {latest['html_url']}") + + +def wait_for_packagist(version: str) -> None: + deadline = time.time() + int(env("PACKAGIST_TIMEOUT", "1800")) + while True: + try: + data = http_json(f"https://repo.packagist.org/p2/marko/core.json?ts={int(time.time())}") + versions = {entry.get("version") for entry in data["packages"]["marko/core"]} + if version in versions: + print(f"Packagist serves marko/core {version}") + return + except (urllib.error.HTTPError, KeyError): + pass + if time.time() > deadline: + fail(f"Packagist did not list marko/core {version} before the timeout") + print("Waiting for Packagist...") + time.sleep(30) + + +def changelog_section(version: str) -> str: + shown = subprocess.run( + ["git", "show", f"refs/tags/{version}:CHANGELOG.md"], capture_output=True, text=True + ) + if shown.returncode != 0: + fail(f"Could not read CHANGELOG.md at tag {version}: {shown.stderr.strip()}") + lines = shown.stdout.splitlines() + start = next((i for i, line in enumerate(lines) if line.startswith(f"## [{version}]")), None) + if start is None: + fail(f"CHANGELOG.md has no section for {version}") + end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("## [")), len(lines)) + section = "\n".join(lines[start:end]).strip() + if "* " not in section: + fail(f"CHANGELOG.md section for {version} lists no changes") + return section + + +def pr_context(section: str) -> str: + parts = [] + for number in sorted(set(re.findall(r"/pull/(\d+)", section)), key=int): + pr = github(f"/pulls/{number}") + body = re.sub(r"\s+\n", "\n", (pr.get("body") or "").strip())[:1500] + parts.append(f"PR #{number}: {pr['title']}\n{body}") + return "\n\n---\n\n".join(parts) + + +def valid_paragraph(text: str) -> str | None: + if not 200 <= len(text) <= 1500: + return f"length {len(text)} is outside 200-1500 characters" + if "\n" in text: + return "it is more than one paragraph" + if re.search(r"[`*#]|^\s*[-•]", text): + return "it contains markdown" + return None + + +def summarize(version: str, section: str, context: str) -> str: + client = anthropic.Anthropic(max_retries=4) + prompt = ( + f"Changelog section for Marko {version}:\n\n{section}\n\n" + f"Descriptions of the pull requests it lists:\n\n{context}" + ) + problem = "no attempt made" + for _ in range(3): + response = client.beta.messages.create( + model=MODEL, + max_tokens=16000, + betas=["server-side-fallback-2026-07-01"], + fallbacks="default", + output_config={"effort": "high"}, + system=SYSTEM_PROMPT, + messages=[{"role": "user", "content": prompt}], + ) + if response.stop_reason == "refusal": + problem = "the model declined the request" + continue + if response.stop_reason != "end_turn": + problem = f"generation stopped with {response.stop_reason}" + continue + text = " ".join( + block.text.strip() for block in response.content if block.type == "text" + ).strip() + problem = valid_paragraph(text) or "" + if not problem: + return text + print(f"Rejected summary ({problem}), retrying") + fail(f"Could not get a usable summary from Claude: {problem}") + raise AssertionError # unreachable + + +def linkified(text: str) -> list[dict]: + elements: list[dict] = [] + position = 0 + for match in URL.finditer(text): + if match.start() > position: + elements.append({"type": "text", "text": text[position:match.start()]}) + elements.append({"type": "link", "url": match.group(0), "text": match.group(0)}) + position = match.end() + if position < len(text): + elements.append({"type": "text", "text": text[position:]}) + return elements + + +def preformatted_chunks(section: str) -> list[str]: + chunks: list[str] = [] + current = "" + for line in section.splitlines(): + if current and len(current) + len(line) + 1 > PRE_CHUNK: + chunks.append(current) + current = "" + current = f"{current}\n{line}" if current else line + if current: + chunks.append(current) + return chunks + + +def headline(version: str) -> str: + return f"Marko {version} release" + + +def message(version: str, summary: str, section: str, mention: bool) -> dict: + heading: list[dict] = [] + if mention: + heading.append({"type": "broadcast", "range": "channel"}) + heading.append({"type": "text", "text": " "}) + heading.append({"type": "text", "text": headline(version)}) + + chunks = preformatted_chunks(section) + blocks = [ + { + "type": "rich_text", + "elements": [ + {"type": "rich_text_section", "elements": heading}, + {"type": "rich_text_section", "elements": [{"type": "text", "text": f"\n{summary}\n"}]}, + {"type": "rich_text_preformatted", "elements": linkified(chunks[0])}, + ], + } + ] + for chunk in chunks[1:]: + blocks.append( + {"type": "rich_text", "elements": [{"type": "rich_text_preformatted", "elements": linkified(chunk)}]} + ) + + fallback = f"{' ' if mention else ''}{headline(version)}\n\n{summary}" + return {"text": fallback, "blocks": blocks, "unfurl_links": False, "unfurl_media": False} + + +def already_posted(channel: str, bot_user: str, version: str) -> bool: + history = slack("conversations.history", {"channel": channel, "limit": 200}) + for item in history.get("messages", []): + if item.get("user") == bot_user and headline(version) in item.get("text", ""): + return True + return False + + +def main() -> None: + version = env("VERSION").strip() + if not SEMVER.match(version): + fail(f"VERSION {version!r} is not a plain X.Y.Z release tag") + target = env("TARGET", "both") + if target not in ("releases", "general", "both"): + fail(f"TARGET {target!r} must be releases, general or both") + preview = os.environ.get("PREVIEW", "false") == "true" + + channels = [] + if target in ("releases", "both"): + channels.append(("releases", env("SLACK_RELEASES_CHANNEL"), True)) + if target in ("general", "both"): + channels.append(("general", env("SLACK_GENERAL_CHANNEL"), False)) + + verify_release(version) + verify_split(version) + wait_for_packagist(version) + section = changelog_section(version) + summary = summarize(version, section, pr_context(section)) + print(f"Summary:\n{summary}\n") + + if preview: + for name, _, mention in channels: + print(f"--- #{name} payload ---") + print(json.dumps(message(version, summary, section, mention), indent=2)) + return + + bot_user = slack("auth.test", {})["user_id"] + for name, channel, mention in channels: + if already_posted(channel, bot_user, version): + print(f"#{name} already has the {version} announcement, skipping") + continue + slack("chat.postMessage", {"channel": channel, **message(version, summary, section, mention)}) + print(f"Posted the {version} announcement to #{name}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/release-announce.yml b/.github/workflows/release-announce.yml new file mode 100644 index 00000000..0b204584 --- /dev/null +++ b/.github/workflows/release-announce.yml @@ -0,0 +1,68 @@ +name: Release Announce + +# Posts each release to Slack once it is really out: the tag's package split succeeded and +# Packagist serves the version. #releases gets an @channel mention, #general the same post +# without it. .github/scripts/release-announce.py does the checks and fails the run (posting +# nothing) when any of them does not pass; a re-run never posts the same release twice. +on: + workflow_run: + workflows: [Split Packages] + types: [completed] + workflow_dispatch: + inputs: + version: + description: Release tag to announce, e.g. 0.13.0 + required: true + target: + description: Channels to post to + type: choice + options: [general, releases, both] + default: general + preview: + description: Print the messages instead of posting them + type: boolean + default: true + +permissions: + contents: read + actions: read + pull-requests: read + +jobs: + announce: + # The split runs for branches as well as tags; only tag runs are releases. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.event == 'push' && + !contains(fromJSON('["main", "develop"]'), github.event.workflow_run.head_branch)) + runs-on: ubuntu-latest + timeout-minutes: 45 + concurrency: + group: release-announce-${{ inputs.version || github.event.workflow_run.head_branch }} + cancel-in-progress: false + env: + VERSION: ${{ inputs.version || github.event.workflow_run.head_branch }} + steps: + # The script lives on the default branch; the changelog is read from the tag itself. + - uses: actions/checkout@v7 + + - name: Fetch the release tag + run: git fetch --depth=1 origin "refs/tags/${VERSION}:refs/tags/${VERSION}" + + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install Anthropic SDK + run: pip install anthropic==2.4.0 + + - name: Announce + env: + TARGET: ${{ inputs.target || 'both' }} + PREVIEW: ${{ github.event_name == 'workflow_dispatch' && inputs.preview && 'true' || 'false' }} + GITHUB_TOKEN: ${{ github.token }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + SLACK_RELEASES_CHANNEL: ${{ vars.SLACK_RELEASES_CHANNEL }} + SLACK_GENERAL_CHANNEL: ${{ vars.SLACK_GENERAL_CHANNEL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: python .github/scripts/release-announce.py