From 2ad56231d1c456bc32cdd446f9023cac85f77650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 28 Sep 2026 02:19:33 +0800 Subject: [PATCH] feat(skills): add release announcement workflow --- .agents/skills/release-announcement/SKILL.md | 26 ++ .../release-announcement/agents/openai.yaml | 4 + .../references/editorial-brief.md | 9 + .../references/poster-spec.md | 29 ++ .../release-announcement/scripts/.gitignore | 2 + .../scripts/release_poster.py | 392 ++++++++++++++++++ .../scripts/test_release_poster.py | 83 ++++ 7 files changed, 545 insertions(+) create mode 100644 .agents/skills/release-announcement/SKILL.md create mode 100644 .agents/skills/release-announcement/agents/openai.yaml create mode 100644 .agents/skills/release-announcement/references/editorial-brief.md create mode 100644 .agents/skills/release-announcement/references/poster-spec.md create mode 100644 .agents/skills/release-announcement/scripts/.gitignore create mode 100644 .agents/skills/release-announcement/scripts/release_poster.py create mode 100644 .agents/skills/release-announcement/scripts/test_release_poster.py diff --git a/.agents/skills/release-announcement/SKILL.md b/.agents/skills/release-announcement/SKILL.md new file mode 100644 index 00000000..5ca7aed7 --- /dev/null +++ b/.agents/skills/release-announcement/SKILL.md @@ -0,0 +1,26 @@ +--- +name: release-announcement +description: 从本项目最新的 release-notes Markdown 制作微信群发布短文案与竖版长图,核验贡献者 GitHub ID 并嵌入真实头像。用于版本发布宣传,不负责创建或发布 GitHub Release。 +--- + +# Release announcement + +在本仓库调用 `$release-announcement` 时,交付一张可发微信群的 PNG 长图和一段很短的群文案。默认使用 [版式与文案要求](references/editorial-brief.md);用户另有要求时以用户要求为准。 + +调用方式:Codex 使用 `$release-announcement`;Claude Code Haha 的终端输入 `/release-announcement`。这两个工具都从项目的 `.agents/skills/` 读取同一份技能。 + +## 工作流 + +1. 在仓库根目录运行 `python3 .agents/skills/release-announcement/scripts/release_poster.py scan --repo . --out artifacts/release-posters/latest-scan.json`。它按语义版本比较工作区、当前 Git 历史、本地 `main` 和已有的 `origin/main`,不切换分支、不抓取远程。读取 JSON 中的中文发布说明、来源和 `@GitHubID` 提及;`credit_candidate` 仅表示有明确署名格式,仍需核对上下文。如果来源不是工作区文件,明确告知用户。 + 复测旧版本时加 `--version 0.6.6` 并使用独立输出路径,避免覆盖最新版本的 scan 结果。 +2. 仅依据该版本的中文 release note 提炼主要功能和关键修复;纯修复版的 `features` 留空,纯功能版的 `fixes` 留空,绝不为了填版式虚构内容。有重要模型或供应商更新时可加一个简短区块。每条信息必须能追溯到原文,压缩表述时保留条件和边界。安装提醒只写原文确有的信息。不要把英文译文重复计数。 +3. 对 `credit_candidate: true` 的账号逐个核对发布说明里的贡献项;普通 `@ID` 提及(例如 PR reviewer)不算代码署名。`render` 会通过 GitHub 公共用户 API 校验 ID 并下载真实头像;无法核验时停止并报告,不能臆造头像、错配姓名,不能让图像生成模型重绘头像。正式生成不能使用 `avatar_path` 跳过核验。 +4. 按 [数据格式](references/poster-spec.md) 写 `poster-spec.json`,版本必须与 scan 结果一致。运行 `python3 .agents/skills/release-announcement/scripts/release_poster.py render --scan artifacts/release-posters/latest-scan.json --spec --out `。建议输出到 `artifacts/release-posters/v/`,保留已有文件,文件名加版本或修订号。此脚本固定使用紧凑版式绘制准确中文和头像;不要用 imagegen 绘制正文。 +5. 写一段适合微信群的短文案,保存为同目录的 `wechat-copy.txt`:版本号 + 1 句主要变化 + 1 句感谢,具体细节交给图片。有本次明确署名的贡献者时感谢代码贡献;没有时只感谢反馈与支持,不暗示本版有特定群友提交。文案不要宣称未核实的下载地址或公开发布状态,也不要自动向群发送。若需要下载入口,先检查对应 GitHub Release 是否存在。 +6. 打开成图检查标题、文字换行、头像和 ID 对应、正文是否从顶部很快开始。最后提供 PNG、短文案、所用 release note 来源和任何未核实项。生成物放在被 Git 忽略的 `artifacts/`,不要提交生成图、头像缓存或中间 JSON。 + +## 图像与环境 + +默认长图不需要生成式插画:顶部仅有约 230px 的版本标题,直接进入实际有内容的功能或修复区。这样版式与中文文字最稳定。用户明确想增加插画时,可用 imagegen 只生成无字装饰素材,再用脚本绘制全部文字与真实头像;不能让装饰占去首屏。 + +`scan` 只用 Python 标准库;`render` 需要 Pillow 和可用的中文系统字体。缺依赖时先查看项目已有运行环境,不为一次制图修改生产依赖。GitHub 头像获取是公开只读请求;失败时给出具体账号和原因。 diff --git a/.agents/skills/release-announcement/agents/openai.yaml b/.agents/skills/release-announcement/agents/openai.yaml new file mode 100644 index 00000000..e71709dd --- /dev/null +++ b/.agents/skills/release-announcement/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "版本发布长图" + short_description: "从最新 release note 生成微信群短文案、长图和贡献者头像" + default_prompt: "用 $release-announcement 读取本项目最新发布说明,制作微信群短文案和发布长图。" diff --git a/.agents/skills/release-announcement/references/editorial-brief.md b/.agents/skills/release-announcement/references/editorial-brief.md new file mode 100644 index 00000000..01d18cdc --- /dev/null +++ b/.agents/skills/release-announcement/references/editorial-brief.md @@ -0,0 +1,9 @@ +# 发布图与群文案提示词 + +使用最新 release markdown 的**中文原文**作为唯一内容依据。写给已经在使用 Claude Code Haha 的微信群用户,突出他们最能感受到的变化,避免把每条 commit 都挤进图里。 + +**长图视觉提示词:**1080px 宽、适合手机纵向阅读的简体中文版本更新图。顶部深炭色 Hero 约 230px,只放产品名、版本号和「新版本发布」,无插画、无大图、无大块留白;紧接着进入本版实际有内容的功能或修复区。主体为暖白纸色,深色标题、陶土红小标记、简洁双列功能卡、单列修复清单。纯修复版不要出现「这次新增」或空功能卡。贡献者区展示从 GitHub 实际取得的圆形头像、准确 ID 和对应贡献。正文由确定性字体排版完成,不能依赖生成模型拼写文字或绘制头像。按内容自然增长高度,不压缩字号来塞入更多条目。 + +**提炼提示词:**从发布说明选最多 4 项核心功能、最多 9 项用户能理解的修复;没有的类别保持为空。每项标题尽量不超过 14 个汉字,说明用一句话讲清“现在能做什么”或“之前的问题如何改善”。优先考虑群友直接感受到的变化,避免用开发者内部修复挤掉有用的用户功能。保留必要条件,例如“在设置中开启后”、超时选项、适用平台、临时性或签名状态。模型更新可合并为一个区块。未署名的修复不强行归给贡献者。 + +**群文案提示词:**用自然、简短的群聊语气,最多三句话;写出版本号和最重要的两项变化,最后引导看图。有本版署名贡献者时感谢代码贡献;没有时感谢反馈与支持。只有用户明确说这些贡献者是群成员时才称他们为“群友”。不要把图内清单再抄一遍;不要虚构下载链接或给未核实的 GitHub ID 加 `@`。 diff --git a/.agents/skills/release-announcement/references/poster-spec.md b/.agents/skills/release-announcement/references/poster-spec.md new file mode 100644 index 00000000..043aca14 --- /dev/null +++ b/.agents/skills/release-announcement/references/poster-spec.md @@ -0,0 +1,29 @@ +# `poster-spec.json` 格式 + +运行 `scan` 后,以最新 release note 填写 JSON。字段: + +```json +{ + "version": "0.6.7", + "product": "Claude Code Haha", + "features": [ + {"title": "侧边对话", "body": "输入 /btw,在右侧开启独立的临时对话,不打断主任务。"} + ], + "extras": [ + {"title": "模型与供应商", "headline": "新版模型与映射", "body": "依据本版说明压缩成一句话。"} + ], + "fixes": [ + {"title": "超大会话打不开", "body": "超过 20MB 的历史可以继续打开。"} + ], + "contributors": [ + {"login": "example-user", "contribution": "此人本次被署名的贡献"} + ], + "install_note": "仅在发布说明明确写有安装提醒时填写。", + "footer_url": "github.com/NanmiCoder/cc-haha" +} +``` + +- `version`、`features`、`fixes`、`contributors` 必填;`extras`、`install_note`、`footer_url` 可省略。纯修复版填 `"features": []`,纯功能版填 `"fixes": []`,两者不能同时为空。没有署名贡献者时 `contributors` 为 `[]`。 +- `render --scan` 会校验版本及贡献者 ID 必须是 release note 中文部分的明确署名候选。普通提及不算署名;不要把示例 ID 写入真实结果。 +- 测试或离线排版时,贡献者可以另给 `avatar_path` 并显式传 `--allow-offline-avatars`;正式生成禁止该选项,必须实时核验公开 GitHub 账号与头像。 +- 正式图保留所有已核验、已署名的贡献者;如果名字很多,可以缩短每人的贡献概述,不能遗漏账号或用假头像填位。 diff --git a/.agents/skills/release-announcement/scripts/.gitignore b/.agents/skills/release-announcement/scripts/.gitignore new file mode 100644 index 00000000..7a60b85e --- /dev/null +++ b/.agents/skills/release-announcement/scripts/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/.agents/skills/release-announcement/scripts/release_poster.py b/.agents/skills/release-announcement/scripts/release_poster.py new file mode 100644 index 00000000..1385cafa --- /dev/null +++ b/.agents/skills/release-announcement/scripts/release_poster.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""Find a release note and render a compact, text-accurate WeChat poster.""" + +import argparse +import hashlib +import json +import re +import subprocess +import sys +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import Request, urlopen + + +VERSION_RE = re.compile(r"^release-notes/v(\d+)\.(\d+)\.(\d+)\.md$") +LOGIN_RE = re.compile(r"(?= 2: + sections = [ + markdown[heading.start():(headings[index + 1].start() if index + 1 < len(headings) else len(markdown))] + for index, heading in enumerate(headings) + ] + else: + sections = [markdown] + # Older releases put English first and Chinese in
; others invert + # that order or repeat the H1 without
. + candidates = [] + for section in sections: + candidates.extend(re.split(r"(?i)]*)?>", section)) + selected = max(candidates, key=lambda part: len(re.findall(r"[\u4e00-\u9fff]", part))) + if len(re.findall(r"[\u4e00-\u9fff]", selected)) < 10: + raise ValueError("发布说明里未找到足够的中文正文,请人工核对语言版本") + return re.sub(r"(?is)]*>|]*>.*?", "", selected).strip() + "\n" + + +def is_explicit_credit(line, login): + escaped = re.escape(login) + if re.search(rf"\*\*@{escaped}\*\*", line, re.IGNORECASE): + return True + return bool(re.search(rf"(?:感谢|致谢|代码贡献者|贡献者)[^\n@]{{0,70}}@{escaped}\b", line, re.IGNORECASE)) + + +def wrap_lines(value, width, measure): + result, current = [], "" + # Latin model names and IDs stay together; CJK wraps character by character. + tokens = re.findall(r"[A-Za-z0-9][A-Za-z0-9._/+:-]*|.", str(value), flags=re.DOTALL) + for token in tokens: + if token == "\n": + result.append(current) + current = "" + continue + trial = current + token + if current and measure(trial) > width: + if token in CLOSING_PUNCTUATION: + tail_match = re.search(r"(?:[A-Za-z0-9._/+:-]+|.)$", current) + tail = tail_match.group(0) if tail_match else "" + prefix = current[:-len(tail)].rstrip() if tail else "" + if prefix: + result.append(prefix) + current = tail + token + else: + current = trial + elif current[-1] in OPENING_PUNCTUATION and len(current) > 1: + result.append(current[:-1].rstrip()) + current = current[-1] + token + else: + result.append(current.rstrip()) + current = token.lstrip() + else: + current = trial + result.append(current) + return result + + +def scan(repo, requested_version=None): + candidates = release_candidates(repo) + if requested_version: + requested = requested_version.removeprefix("v") + if not re.fullmatch(r"\d+\.\d+\.\d+", requested): + raise ValueError(f"无效版本号:{requested_version}") + target = tuple(map(int, requested.split("."))) + candidates = [candidate for candidate in candidates if candidate[0] == target] + if not candidates: + label = requested_version or "最新版本" + raise ValueError(f"未找到 {label} 的 release-notes Markdown") + version, _, source, relative = max(candidates) + if source == "working-tree": + markdown = (repo / relative).read_text(encoding="utf-8") + else: + result = git(repo, "show", f"{source}:{relative}") + if result.returncode: + raise ValueError(f"无法从 {source} 读取 {relative}: {result.stderr.strip()}") + markdown = result.stdout + + chinese = chinese_release_section(markdown) + mentions = {} + for line in chinese.splitlines(): + for match in LOGIN_RE.finditer(line): + login = match.group(1) + key = login.lower() + entry = mentions.setdefault(key, {"login": login, "lines": [], "credit_candidate": False}) + if line not in entry["lines"]: + entry["lines"].append(line.strip()) + if is_explicit_credit(line, login): + entry["credit_candidate"] = True + + return { + "version": ".".join(map(str, version)), + "source": source, + "path": relative, + "sha256": hashlib.sha256(chinese.encode("utf-8")).hexdigest(), + "chinese_markdown": chinese, + "mentions": list(mentions.values()), + } + + +def font_paths(bold): + mac = "/System/Library/Fonts/STHeiti Medium.ttc" if bold else "/System/Library/Fonts/STHeiti Light.ttc" + linux = "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc" if bold else "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" + windows = "C:/Windows/Fonts/msyhbd.ttc" if bold else "C:/Windows/Fonts/msyh.ttc" + return [mac, linux, windows, "/System/Library/Fonts/Hiragino Sans GB.ttc"] + + +def choose_font_path(bold): + for candidate in font_paths(bold): + if Path(candidate).is_file(): + return candidate + raise RuntimeError("缺少可用的中文系统字体;请提供宋体、黑体或 Noto CJK 字体") + + +def check_spec(spec, scan_data, allow_offline_avatars=False): + if not isinstance(spec, dict): + raise ValueError("poster spec 必须是 JSON 对象") + if spec.get("version") != scan_data.get("version"): + raise ValueError("poster spec 的 version 与最新 release note 不一致") + for field in ("features", "fixes", "contributors"): + if not isinstance(spec.get(field), list): + raise ValueError(f"poster spec 缺少数组字段 {field}") + if not spec["features"] and not spec["fixes"]: + raise ValueError("至少需要一个主要功能或修复项") + if len(spec["features"]) > 6 or len(spec["fixes"]) > 12: + raise ValueError("内容过多:最多 6 项功能、12 项修复,请先提炼") + mention_ids = {m["login"].lower() for m in scan_data.get("mentions", []) if m.get("credit_candidate") is True} + seen = set() + for person in spec["contributors"]: + login = person.get("login", "") + if not isinstance(login, str) or not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?", login): + raise ValueError(f"无效 GitHub ID: {login!r}") + if login.lower() not in mention_ids: + raise ValueError(f"贡献者 @{login} 未在 release note 中文部分明确署名") + if login.lower() in seen: + raise ValueError(f"重复的贡献者 @{login}") + seen.add(login.lower()) + if not person.get("contribution"): + raise ValueError(f"缺少 @{login} 的贡献概述") + if person.get("avatar_path") and not allow_offline_avatars: + raise ValueError("avatar_path 仅供显式离线测试使用,正式成图必须核验 GitHub 头像") + for field in ("features", "fixes"): + for item in spec[field]: + if not item.get("title") or not item.get("body"): + raise ValueError(f"{field} 每项都需要 title 和 body") + + +def get_avatar(person, spec_path, avatar_dir, allow_offline_avatars=False): + from PIL import Image + + login = person["login"] + if person.get("avatar_path"): + if not allow_offline_avatars: + raise ValueError("正式成图不能使用 avatar_path") + path = Path(person["avatar_path"]) + if not path.is_absolute(): + path = spec_path.parent / path + return Image.open(path).convert("RGB") + + request = Request( + "https://api.github.com/users/" + quote(login), + headers={"Accept": "application/vnd.github+json", "User-Agent": "cc-haha-release-announcement"}, + ) + try: + with urlopen(request, timeout=20) as response: + profile = json.load(response) + if profile.get("login", "").lower() != login.lower(): + raise ValueError(f"GitHub 返回的账号与 @{login} 不符") + url = profile.get("avatar_url") + if not url: + raise ValueError(f"@{login} 没有公开头像地址") + image_request = Request(url + "&s=256", headers={"User-Agent": "cc-haha-release-announcement"}) + with urlopen(image_request, timeout=20) as response: + content = response.read() + except (HTTPError, URLError, TimeoutError) as exc: + raise RuntimeError(f"获取 @{login} 的 GitHub 头像失败:{exc}") from exc + avatar_dir.mkdir(parents=True, exist_ok=True) + path = avatar_dir / (login + ".png") + path.write_bytes(content) + return Image.open(path).convert("RGB") + + +def render(spec, scan_data, spec_path, output, allow_offline_avatars=False): + from PIL import Image, ImageDraw, ImageFont, ImageOps + + check_spec(spec, scan_data, allow_offline_avatars) + regular_path, bold_path = choose_font_path(False), choose_font_path(True) + def face(size, bold=False): + return ImageFont.truetype(bold_path if bold else regular_path, size) + + W, MAX_H = 1080, 16000 + paper, ink, muted, rust, line = "#F8F5EE", "#25211D", "#746B62", "#A84D35", "#DED5C9" + canvas = Image.new("RGB", (W, MAX_H), paper) + draw = ImageDraw.Draw(canvas) + + def lines(value, text_face, width): + return wrap_lines(value, width, lambda text: draw.textlength(text, font=text_face)) + + def write(x, y, value, text_face, color, width, leading=1.38): + step = round(text_face.size * leading) + for text_line in lines(value, text_face, width): + draw.text((x, y), text_line, font=text_face, fill=color) + y += step + return y + + # Compact hero: the first release content appears in the first phone viewport. + draw.rectangle((0, 0, W, 230), fill=ink) + product = str(spec.get("product") or "Claude Code Haha").upper() + write(64, 40, f"{product} · v{spec['version']}", face(29, True), "#D6B99E", 950) + draw.text((64, 99), "新版本发布", font=face(63, True), fill="#F4EFE4") + draw.line((64, 207, 1016, 207), fill=rust, width=4) + + y = 286 + features = spec["features"] + if features: + draw.text((64, y), "这次新增", font=face(58, True), fill=ink) + draw.text((68, y + 80), "本次最值得关注的变化", font=face(30), fill=muted) + y += 150 + cols = 1 if len(features) == 1 else 2 + gap_x, gap_y = 28, 28 + for start in range(0, len(features), cols): + row = features[start:start + cols] + card_w = 952 if len(row) == 1 else 461 + dimensions = [] + for item in row: + title_lines = lines(item["title"], face(37, True), card_w - 60) + body_lines = lines(item["body"], face(30), card_w - 60) + body_top = 87 + len(title_lines) * 45 + 24 + dimensions.append((title_lines, body_lines, body_top)) + minimum_height = 240 if len(row) == 1 else 340 + height = max(minimum_height, *(body_top + len(body_lines) * 42 + 35 for _, body_lines, body_top in dimensions)) + for col, (item, (_, _, body_top)) in enumerate(zip(row, dimensions)): + x = 64 + col * (card_w + gap_x) + draw.rounded_rectangle((x, y, x + card_w, y + height), radius=26, fill="#FFFFFF", outline=line, width=2) + draw.text((x + 30, y + 28), f"{start + col + 1:02d}", font=face(28, True), fill=rust) + write(x + 30, y + 87, item["title"], face(37, True), ink, card_w - 60, 1.22) + write(x + 30, y + body_top, item["body"], face(30), muted, card_w - 60, 1.4) + y += height + gap_y + y += 66 + + for extra in spec.get("extras", []): + if not all(extra.get(key) for key in ("title", "headline", "body")): + raise ValueError("extras 每项需要 title、headline、body") + draw.text((64, y), extra["title"], font=face(54, True), fill=ink) + y += 90 + head_lines = lines(extra["headline"], face(34, True), 890) + body_lines = lines(extra["body"], face(30), 880) + box_h = 30 + len(head_lines) * 45 + 20 + len(body_lines) * 40 + 30 + draw.rounded_rectangle((64, y, 1016, y + box_h), radius=25, fill="#EEE4D7") + write(94, y + 30, extra["headline"], face(34, True), ink, 890, 1.32) + write(94, y + 30 + len(head_lines) * 45 + 20, extra["body"], face(30), muted, 880, 1.38) + y += box_h + 80 + + if spec["fixes"]: + draw.text((64, y), "重点修复", font=face(58, True), fill=ink) + draw.text((68, y + 80), "那些真正影响使用的问题", font=face(30), fill=muted) + y += 160 + for index, item in enumerate(spec["fixes"]): + row_y = y + draw.ellipse((67, y + 16, 83, y + 32), fill=rust) + title_end = write(107, y, item["title"], face(34, True), ink, 870, 1.2) + body_end = write(107, title_end + 12, item["body"], face(30), muted, 870, 1.36) + y = max(row_y + 116, body_end + 24) + if index < len(spec["fixes"]) - 1: + draw.line((107, y - 13, 1016, y - 13), fill=line, width=2) + y += 56 + + people = spec["contributors"] + if people: + draw.text((64, y), "感谢社区贡献者", font=face(54, True), fill=ink) + draw.text((67, y + 78), "头像与 GitHub ID 来自公开主页", font=face(28), fill=muted) + y += 150 + avatar_dir = output.parent / "avatars" + avatars = [(person, get_avatar(person, spec_path, avatar_dir, allow_offline_avatars)) for person in people] + for person, photo in avatars: + contribution_lines = lines(person["contribution"], face(24), 805) + row_h = max(116, 75 + len(contribution_lines) * 31) + draw.rounded_rectangle((64, y, 1016, y + row_h), radius=20, fill="#FFFFFF", outline=line, width=2) + square = ImageOps.fit(photo, (78, 78), method=Image.Resampling.LANCZOS) + mask = Image.new("L", (78, 78)) + ImageDraw.Draw(mask).ellipse((0, 0, 77, 77), fill=255) + canvas.paste(square, (86, y + 19), mask) + draw.text((184, y + 23), "@" + person["login"], font=face(30, True), fill=ink) + write(184, y + 66, person["contribution"], face(24), muted, 805) + y += row_h + 16 + y += 20 + + if spec.get("install_note"): + note_lines = lines(spec["install_note"], face(27), 885) + box_h = 77 + len(note_lines) * 38 + 30 + draw.rounded_rectangle((64, y, 1016, y + box_h), radius=25, fill=ink) + draw.text((96, y + 28), "安装提示", font=face(31, True), fill="#F5D3B4") + write(96, y + 76, spec["install_note"], face(27), "#F4EFE4", 885) + y += box_h + 68 + + draw.text((64, y), f"{spec.get('product') or 'Claude Code Haha'} · v{spec['version']}", font=face(28, True), fill=ink) + if spec.get("footer_url"): + draw.text((64, y + 48), spec["footer_url"], font=face(27), fill=muted) + y += 137 + if y > MAX_H: + raise ValueError(f"海报高度 {y}px 超出上限,请减少内容") + output.parent.mkdir(parents=True, exist_ok=True) + canvas.crop((0, 0, W, y)).save(output, optimize=True) + return (W, y) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + scan_cmd = commands.add_parser("scan", help="查找最新 release note,并输出中文内容与署名候选") + scan_cmd.add_argument("--repo", type=Path, default=Path.cwd()) + scan_cmd.add_argument("--version", help="指定旧版本进行复测,例如 0.6.6;默认选最新版本") + scan_cmd.add_argument("--out", type=Path, required=True) + render_cmd = commands.add_parser("render", help="从经过审核的 JSON 绘制微信群长图") + render_cmd.add_argument("--scan", type=Path, required=True) + render_cmd.add_argument("--spec", type=Path, required=True) + render_cmd.add_argument("--out", type=Path, required=True) + render_cmd.add_argument("--allow-offline-avatars", action="store_true", help="仅测试用:允许 avatar_path 跳过 GitHub 请求") + args = parser.parse_args() + try: + if args.command == "scan": + data = scan(args.repo.resolve(), args.version) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + credited = sum(mention["credit_candidate"] for mention in data["mentions"]) + print(f"v{data['version']} | {data['source']}:{data['path']} | {credited} credits / {len(data['mentions'])} mentions | {args.out}") + else: + scan_data = json.loads(args.scan.read_text(encoding="utf-8")) + spec = json.loads(args.spec.read_text(encoding="utf-8")) + width, height = render(spec, scan_data, args.spec, args.out, args.allow_offline_avatars) + print(f"{args.out} | {width}x{height}") + except (ValueError, RuntimeError, OSError, KeyError, json.JSONDecodeError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/release-announcement/scripts/test_release_poster.py b/.agents/skills/release-announcement/scripts/test_release_poster.py new file mode 100644 index 00000000..ea6dc861 --- /dev/null +++ b/.agents/skills/release-announcement/scripts/test_release_poster.py @@ -0,0 +1,83 @@ +import tempfile +import unittest +from pathlib import Path + +from release_poster import check_spec, chinese_release_section, scan, wrap_lines + + +class ReleasePosterTest(unittest.TestCase): + def test_latest_semver_and_chinese_credits(self): + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + notes = repo / "release-notes" + notes.mkdir() + (notes / "v0.6.9.md").write_text("# v0.6.9\n本次版本修复桌面端加载问题 **@latest-user**\n
\n@english-only\n", encoding="utf-8") + (notes / "v0.6.8.md").write_text("# v0.6.8\n本次版本修复桌面端加载问题 **@old-user**\n", encoding="utf-8") + result = scan(repo) + self.assertEqual(result["version"], "0.6.9") + self.assertEqual([person["login"] for person in result["mentions"]], ["latest-user"]) + older = scan(repo, "v0.6.8") + self.assertEqual(older["version"], "0.6.8") + self.assertEqual([person["login"] for person in older["mentions"]], ["old-user"]) + + def test_chinese_section_in_either_order_and_without_details(self): + title = "# Claude Code Haha v0.5.4\n" + english_first = title + "English changes here.\n
\n中文版本\n" + title + "这里是中文修复内容和版本更新。\n
" + chinese_first = title + "这里是中文修复内容和版本更新。\n
\nEnglish\n" + title + "English changes here.\n
" + no_details = title + "这里是中文修复内容和版本更新。\n" + title + "English changes here.\n" + for markdown in (english_first, chinese_first, no_details): + selected = chinese_release_section(markdown) + self.assertIn("这里是中文修复内容", selected) + self.assertNotIn("English changes", selected) + + def test_incidental_mention_is_not_contributor_credit(self): + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + notes = repo / "release-notes" + notes.mkdir() + (notes / "v0.2.0.md").write_text( + "# Claude Code Haha v0.2.0\n本次修复桌面问题。\n- PR 模板默认要求 @dosubot review。\n- 修复连接问题 **@actual-author**(#42)。\n", + encoding="utf-8", + ) + result = scan(repo) + candidates = {person["login"]: person["credit_candidate"] for person in result["mentions"]} + self.assertEqual(candidates, {"dosubot": False, "actual-author": True}) + spec = { + "version": "0.2.0", "features": [], "fixes": [{"title": "修复", "body": "说明"}], + "contributors": [{"login": "dosubot", "contribution": "代码贡献"}], + } + with self.assertRaisesRegex(ValueError, "未在 release note 中文部分明确署名"): + check_spec(spec, result) + + def test_cannot_credit_unmentioned_account(self): + source = {"version": "0.6.7", "mentions": [{"login": "actual-author", "credit_candidate": True}]} + spec = { + "version": "0.6.7", + "features": [{"title": "功能", "body": "说明"}], + "fixes": [{"title": "修复", "body": "说明"}], + "contributors": [{"login": "different-author", "contribution": "贡献"}], + } + with self.assertRaisesRegex(ValueError, "未在 release note 中文部分明确署名"): + check_spec(spec, source) + + def test_offline_avatar_requires_explicit_test_mode(self): + source = {"version": "0.6.7", "mentions": [{"login": "actual-author", "credit_candidate": True}]} + spec = { + "version": "0.6.7", "features": [], "fixes": [{"title": "修复", "body": "说明"}], + "contributors": [{"login": "actual-author", "contribution": "修复", "avatar_path": "fake.png"}], + } + with self.assertRaisesRegex(ValueError, "仅供显式离线测试"): + check_spec(spec, source) + check_spec(spec, source, allow_offline_avatars=True) + + def test_chinese_punctuation_does_not_start_a_wrapped_line(self): + width = 4 + measure = len + for value in ("这是测试;继续", "这是测试。继续", "这是测试、继续", "测试「功能描述"): + lines = wrap_lines(value, width, measure) + self.assertTrue(all(not line.startswith((";", "。", "、")) for line in lines[1:])) + self.assertTrue(all(not line.endswith("「") for line in lines[:-1])) + + +if __name__ == "__main__": + unittest.main()