feat(skills): add release announcement workflow

This commit is contained in:
程序员阿江(Relakkes)
2026-09-28 02:19:33 +08:00
parent ab5f66f101
commit 2ad56231d1
7 changed files with 545 additions and 0 deletions
@@ -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 <poster-spec.json> --out <poster.png>`。建议输出到 `artifacts/release-posters/v<version>/`,保留已有文件,文件名加版本或修订号。此脚本固定使用紧凑版式绘制准确中文和头像;不要用 imagegen 绘制正文。
5. 写一段适合微信群的短文案,保存为同目录的 `wechat-copy.txt`:版本号 + 1 句主要变化 + 1 句感谢,具体细节交给图片。有本次明确署名的贡献者时感谢代码贡献;没有时只感谢反馈与支持,不暗示本版有特定群友提交。文案不要宣称未核实的下载地址或公开发布状态,也不要自动向群发送。若需要下载入口,先检查对应 GitHub Release 是否存在。
6. 打开成图检查标题、文字换行、头像和 ID 对应、正文是否从顶部很快开始。最后提供 PNG、短文案、所用 release note 来源和任何未核实项。生成物放在被 Git 忽略的 `artifacts/`,不要提交生成图、头像缓存或中间 JSON。
## 图像与环境
默认长图不需要生成式插画:顶部仅有约 230px 的版本标题,直接进入实际有内容的功能或修复区。这样版式与中文文字最稳定。用户明确想增加插画时,可用 imagegen 只生成无字装饰素材,再用脚本绘制全部文字与真实头像;不能让装饰占去首屏。
`scan` 只用 Python 标准库;`render` 需要 Pillow 和可用的中文系统字体。缺依赖时先查看项目已有运行环境,不为一次制图修改生产依赖。GitHub 头像获取是公开只读请求;失败时给出具体账号和原因。
@@ -0,0 +1,4 @@
interface:
display_name: "版本发布长图"
short_description: "从最新 release note 生成微信群短文案、长图和贡献者头像"
default_prompt: "用 $release-announcement 读取本项目最新发布说明,制作微信群短文案和发布长图。"
@@ -0,0 +1,9 @@
# 发布图与群文案提示词
使用最新 release markdown 的**中文原文**作为唯一内容依据。写给已经在使用 Claude Code Haha 的微信群用户,突出他们最能感受到的变化,避免把每条 commit 都挤进图里。
**长图视觉提示词:**1080px 宽、适合手机纵向阅读的简体中文版本更新图。顶部深炭色 Hero 约 230px,只放产品名、版本号和「新版本发布」,无插画、无大图、无大块留白;紧接着进入本版实际有内容的功能或修复区。主体为暖白纸色,深色标题、陶土红小标记、简洁双列功能卡、单列修复清单。纯修复版不要出现「这次新增」或空功能卡。贡献者区展示从 GitHub 实际取得的圆形头像、准确 ID 和对应贡献。正文由确定性字体排版完成,不能依赖生成模型拼写文字或绘制头像。按内容自然增长高度,不压缩字号来塞入更多条目。
**提炼提示词:**从发布说明选最多 4 项核心功能、最多 9 项用户能理解的修复;没有的类别保持为空。每项标题尽量不超过 14 个汉字,说明用一句话讲清“现在能做什么”或“之前的问题如何改善”。优先考虑群友直接感受到的变化,避免用开发者内部修复挤掉有用的用户功能。保留必要条件,例如“在设置中开启后”、超时选项、适用平台、临时性或签名状态。模型更新可合并为一个区块。未署名的修复不强行归给贡献者。
**群文案提示词:**用自然、简短的群聊语气,最多三句话;写出版本号和最重要的两项变化,最后引导看图。有本版署名贡献者时感谢代码贡献;没有时感谢反馈与支持。只有用户明确说这些贡献者是群成员时才称他们为“群友”。不要把图内清单再抄一遍;不要虚构下载链接或给未核实的 GitHub ID 加 `@`。
@@ -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 账号与头像。
- 正式图保留所有已核验、已署名的贡献者;如果名字很多,可以缩短每人的贡献概述,不能遗漏账号或用假头像填位。
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
@@ -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"(?<![A-Za-z0-9._%+-])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)")
RELEASE_HEADING_RE = re.compile(r"(?m)^#\s+Claude Code Haha v\d+\.\d+\.\d+\s*$")
CLOSING_PUNCTUATION = set(",。、;:!?)》」』】〕”’%")
OPENING_PUNCTUATION = set("(《「『【〔“‘")
def git(repo, *args):
return subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
check=False,
)
def release_candidates(repo):
notes = repo / "release-notes"
candidates = []
if notes.is_dir():
for path in notes.glob("v*.md"):
relative = path.relative_to(repo).as_posix()
match = VERSION_RE.fullmatch(relative)
if match:
candidates.append((tuple(map(int, match.groups())), 4, "working-tree", relative))
for rank, ref in ((3, "HEAD"), (2, "main"), (1, "origin/main")):
result = git(repo, "ls-tree", "-r", "--name-only", ref, "--", "release-notes")
if result.returncode:
continue
for relative in result.stdout.splitlines():
match = VERSION_RE.fullmatch(relative)
if match:
candidates.append((tuple(map(int, match.groups())), rank, ref, relative))
return candidates
def chinese_release_section(markdown):
headings = list(RELEASE_HEADING_RE.finditer(markdown))
if len(headings) >= 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 <details>; others invert
# that order or repeat the H1 without <details>.
candidates = []
for section in sections:
candidates.extend(re.split(r"(?i)<details(?:\s[^>]*)?>", 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)</?details[^>]*>|<summary[^>]*>.*?</summary>", "", 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())
@@ -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<details>\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<details>\n<summary>中文版本</summary>\n" + title + "这里是中文修复内容和版本更新。\n</details>"
chinese_first = title + "这里是中文修复内容和版本更新。\n<details>\n<summary>English</summary>\n" + title + "English changes here.\n</details>"
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()