ci: preserve manual release summaries and raw changelog

This commit is contained in:
jxxghp
2026-10-10 08:37:30 +08:00
parent d7db4cfc0b
commit 63e30e453b
5 changed files with 262 additions and 114 deletions

View File

@@ -240,106 +240,9 @@ jobs:
type=gha,scope=moviepilot-v3t-docker-arm64,version=2
cache-to: type=gha,scope=moviepilot-v3t-docker,mode=max,version=2
- name: Generate Changelog
id: changelog
run: |
# 获取上一个 tag(优先 v3.*,回退到任意 v* 版本 tag)
PREVIOUS_TAG=$(git tag -l 'v3.*' --sort=-v:refname | grep -v "^v${{ env.app_version }}$" | head -n 1)
if [ -z "$PREVIOUS_TAG" ]; then
PREVIOUS_TAG=$(git tag -l 'v[0-9]*' --sort=-v:refname | grep -v "^v${{ env.app_version }}$" | head -n 1)
fi
echo "Previous tag: $PREVIOUS_TAG"
# 使用 || 作为分隔符,同时获取 commit 消息和作者 GitHub 用户名
if [ -z "$PREVIOUS_TAG" ]; then
# 首次发布且无任何历史版本 tag,限制条数避免打印整库历史撑爆环境变量
COMMITS=$(git log --pretty=format:"%s||%an" -n 300 "${SOURCE_COMMIT}")
else
COMMITS=$(git log --pretty=format:"%s||%an" "${PREVIOUS_TAG}..${SOURCE_COMMIT}")
fi
# 分类收集 commit 消息(使用关联数组去重)
declare -A SEEN
FEATURES=""
FIXES=""
OTHERS=""
while IFS= read -r line; do
# 跳过空行
if [ -z "$line" ]; then
continue
fi
# 分离 commit 消息和作者
msg=$(echo "$line" | sed 's/||[^|]*$//')
author=$(echo "$line" | sed 's/.*||//')
# 跳过 Merge commit 和版本更新 commit
if echo "$msg" | grep -qE "^Merge pull request|^Merge branch|^更新 version"; then
continue
fi
# 按 Conventional Commits 前缀分类
if echo "$msg" | grep -qiE "^feat(\(.+\))?:"; then
desc=$(echo "$msg" | sed -E 's/^feat(\([^)]*\))?:\s*//')
category="FEATURES"
elif echo "$msg" | grep -qiE "^fix(\(.+\))?:"; then
desc=$(echo "$msg" | sed -E 's/^fix(\([^)]*\))?:\s*//')
category="FIXES"
elif echo "$msg" | grep -qiE "^(docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?:"; then
desc=$(echo "$msg" | sed -E 's/^(docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]*\))?:\s*//')
category="OTHERS"
else
desc="$msg"
category="OTHERS"
fi
# 使用 "分类+描述" 作为去重的 key,跳过重复内容
dedup_key="${category}::${desc}"
if [ -n "${SEEN[$dedup_key]+x}" ]; then
continue
fi
SEEN[$dedup_key]=1
# 添加 by @author 引用
entry="- ${desc} by @${author}"
case "$category" in
FEATURES) FEATURES="${FEATURES}${entry}\n" ;;
FIXES) FIXES="${FIXES}${entry}\n" ;;
OTHERS) OTHERS="${OTHERS}${entry}\n" ;;
esac
done <<< "$COMMITS"
# 组装 changelog
CHANGELOG=""
if [ -n "$FEATURES" ]; then
CHANGELOG="${CHANGELOG}### ✨ 新功能\n\n${FEATURES}\n"
fi
if [ -n "$FIXES" ]; then
CHANGELOG="${CHANGELOG}### 🐛 修复\n\n${FIXES}\n"
fi
if [ -n "$OTHERS" ]; then
CHANGELOG="${CHANGELOG}### 🔧 其他\n\n${OTHERS}\n"
fi
# 添加版本对比链接
if [ -n "$PREVIOUS_TAG" ]; then
CHANGELOG="${CHANGELOG}**完整更新记录**: https://github.com/${{ github.repository }}/compare/${PREVIOUS_TAG}...v${{ env.app_version }}"
fi
# 写入环境变量
echo "CHANGELOG<<EOF" >> $GITHUB_ENV
echo -e "$CHANGELOG" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Get existing release body
id: get_release_body
env:
CHANGELOG: ${{ env.CHANGELOG }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag_name="v${{ env.app_version }}"
@@ -364,20 +267,25 @@ jobs:
fi
echo "exists=$release_exists" >> "$GITHUB_OUTPUT"
# 如果已有手动编写的 release body,则保留;否则使用自动生成的 changelog
if [ -n "$release_body" ] && [ "$release_body" != "null" ] && [ "$release_body" != "" ]; then
{
echo "RELEASE_BODY<<EOF"
echo "$release_body"
echo "EOF"
} >> "$GITHUB_ENV"
else
{
echo "RELEASE_BODY<<EOF"
echo "$CHANGELOG"
echo "EOF"
} >> "$GITHUB_ENV"
# 随机分隔符避免手工正文中的 EOF 等独立行截断工作流环境变量。
delimiter="RELEASE_NOTES_$(python3 -c 'import uuid; print(uuid.uuid4().hex)')"
if [ "$release_body" = "null" ]; then
release_body=""
fi
{
printf 'RELEASE_BODY<<%s\n' "$delimiter"
printf '%s\n' "$release_body"
printf '%s\n' "$delimiter"
} >> "$GITHUB_ENV"
- name: Generate Release Notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python3 scripts/release_notes.py \
--repository "$GITHUB_REPOSITORY" \
--tag "v${{ env.app_version }}" \
--source "$SOURCE_COMMIT"
- name: Publish Release Tag
env:

View File

@@ -2,7 +2,7 @@
## Commit Conventions
This project uses **Conventional Commits**. The release workflow parses commit messages to categorize changelog entries. This is not stylistic — it is functional.
This project uses **Conventional Commits**. Release notes preserve commit subjects without categorization or rewriting.
### Format
@@ -74,6 +74,20 @@ ci: improve docker build cache
---
## Release Notes
正式后端 Release 固定包含三个章节:`## 本次更新内容`、`## 完整变更记录`、`## 版本对比`。
首个章节留空,由维护者在 GitHub Release 中手工填写;同版本重建保留该章节内容,仅刷新后两个自动章节。
旧版手工正文迁入首个章节,旧版自动分类记录由新格式替换。
工作流通过 `scripts/release_notes.py` 收集上一同大版本正式标签到固定来源 SHA 的所有分页提交,
保留原始标题、提交链接与真实 GitHub `@作者`,不分类、翻译或按标题合并。
仅过滤 Merge 和构建生成的插件市场发布快照提交。未关联 GitHub 账号时展示 Git 作者名,不伪造账号。
兼容历史 `v3.x.y-N` 数字修订标签;本版本、较高版本和命名预发布标签不会成为比较起点;首次发布无起点时版本对比章节留空。
前端 Release 仅生成相同规则的 changelog 列表,不包含摘要、其它章节或 Compare 链接。
---
## CI/CD
- CI runs on every push and pull request. The pipeline typically includes:

112
scripts/release_notes.py Normal file
View File

@@ -0,0 +1,112 @@
"""发布工作流使用的原始提交记录生成器,不分类或改写提交标题。"""
import argparse
import json
import os
import re
import subprocess
import uuid
from pathlib import Path
from typing import Any
from urllib.parse import quote
def command(*args: str) -> str:
"""以参数列表执行发布工具;查询失败时终止,避免发布不完整记录。"""
return subprocess.run(args, check=True, capture_output=True, text=True).stdout
def previous_tag(tag: str) -> str | None:
"""选取低于当前版本的最近正式标签,兼容历史数字修订后缀,排除本版本和命名预发布标签。"""
version = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)(?:-(\d+))?", tag)
if version is None:
raise ValueError(f"不支持的正式版本标签:{tag}")
current = tuple(int(part or 0) for part in version.groups())
candidates = []
for candidate in command("git", "tag", "--list", "v*").splitlines():
match = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)(?:-(\d+))?", candidate)
if match is not None:
number = tuple(int(part or 0) for part in match.groups())
if number[0] == current[0] and number < current:
candidates.append((number, candidate))
return max(candidates)[1] if candidates else None
def collect_commits(repository: str, source: str, previous: str | None) -> list[dict[str, Any]]:
"""分页读取指定发布范围的提交及真实 GitHub 作者账号,支持直接提交。"""
if previous:
endpoint = f"repos/{repository}/compare/{quote(previous, safe='')}...{source}?per_page=100"
pages = json.loads(command("gh", "api", "--paginate", "--slurp", endpoint))
commits = [commit for page in pages for commit in page["commits"]]
else:
endpoint = f"repos/{repository}/commits?sha={source}&per_page=100"
pages = json.loads(command("gh", "api", "--paginate", "--slurp", endpoint))
commits = [commit for page in pages for commit in page]
commits.reverse()
return commits
def changelog(commits: list[dict[str, Any]]) -> str:
"""保留提交标题、作者与链接,仅排除合并和自动发布快照,不合并同名提交。"""
entries = []
seen = set()
for commit in commits:
sha = commit["sha"]
if sha in seen:
continue
seen.add(sha)
subject = commit["commit"]["message"].splitlines()[0]
if len(commit.get("parents", [])) > 1 or subject.startswith("Merge "):
continue
if subject.startswith("build(plugin-market): sync default from MoviePilot-Wiki@"):
continue
author = commit.get("author") or {}
login = author.get("login")
# 未关联 GitHub 账号的提交保留显示名,不伪造 @账号。
attribution = f"@{login}" if login else commit["commit"]["author"]["name"]
entries.append(f"- {subject} by {attribution} ([{sha[:7]}]({commit['html_url']}))")
return "\n".join(entries)
def manual_summary(body: str) -> str:
"""保留手工摘要及内部 Markdown;旧版自动记录不迁入用户摘要。"""
match = re.search(r"^## 本次更新内容[ \t]*\r?$", body, re.MULTILINE)
if match:
remaining = body[match.end():]
boundary = re.search(r"^## (?:完整变更记录|版本对比)[ \t]*\r?$", remaining, re.MULTILINE)
return remaining[:boundary.start()].strip() if boundary else remaining.strip()
# 兼容已有手工 Release 正文,避免首次切换模板时丢失公告。
legacy_generated = re.search(r"^### (?:✨ 新功能|🐛 修复|🔧 其他)", body, re.MULTILINE)
return body[:legacy_generated.start()].strip() if legacy_generated else body.strip()
def release_body(summary: str, records: str, repository: str, previous: str | None, tag: str) -> str:
"""生成后端固定三章节;首版无比较起点时仅保留空的版本对比章节。"""
comparison = ""
if previous:
url = f"https://github.com/{repository}/compare/{previous}...{tag}"
comparison = f"[完整 Compare]({url})"
return f"## 本次更新内容\n\n{summary}\n\n## 完整变更记录\n\n{records}\n\n## 版本对比\n\n{comparison}\n"
def main() -> None:
"""向发布工作流传递完整正文;前端模式仅输出原始 changelog。"""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", required=True)
parser.add_argument("--tag", required=True)
parser.add_argument("--source", required=True)
parser.add_argument("--changelog-only", action="store_true")
args = parser.parse_args()
previous = previous_tag(args.tag)
records = changelog(collect_commits(args.repository, args.source, previous))
body = records if args.changelog_only else release_body(
manual_summary(os.environ.get("RELEASE_BODY", "")),
records, args.repository, previous, args.tag,
)
delimiter = f"RELEASE_NOTES_{uuid.uuid4().hex}"
with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as output:
output.write(f"RELEASE_BODY<<{delimiter}\n{body}\n{delimiter}\n")
if __name__ == "__main__":
main()

111
tests/test_release_notes.py Normal file
View File

@@ -0,0 +1,111 @@
"""发布记录的范围、作者、分页与手工摘要保护契约。"""
import json
import pytest
from scripts import release_notes
def make_commit(sha: str, subject: str, login: str | None = "contributor", parents: int = 1) -> dict:
"""构造包含 GitHub 作者及原始 Git 显示名的提交响应。"""
return {
"sha": sha,
"html_url": f"https://github.com/owner/repo/commit/{sha}",
"parents": [{}] * parents,
"author": {"login": login} if login else None,
"commit": {"message": subject, "author": {"name": "Display Name"}},
}
def test_changelog_preserves_subjects_and_distinct_commits() -> None:
"""不分类、翻译或按标题去重,真实账号与未关联作者分别展示。"""
commits = [
make_commit("a" * 40, "fix(scope): 原始标题\n详细正文"),
make_commit("b" * 40, "fix(scope): 原始标题", login=None),
make_commit("c" * 40, "Merge pull request #1", parents=2),
make_commit("d" * 40, "合并提交", parents=2),
make_commit("e" * 40, "build(plugin-market): sync default from MoviePilot-Wiki@123"),
make_commit("f" * 40, "ci: improve pipeline"),
]
body = release_notes.changelog(commits + commits[:1])
assert body.count("fix(scope): 原始标题") == 2
assert "by @contributor" in body
assert "by Display Name" in body
assert "@Display Name" not in body
assert "Merge" not in body and "合并提交" not in body
assert "plugin-market" not in body
assert "ci: improve pipeline" in body
assert "详细正文" not in body
def test_previous_tag_excludes_current_future_and_other_major(monkeypatch) -> None:
"""同版本重打包仍从上一正式版计算,不受较高版本或预发布标签影响。"""
monkeypatch.setattr(release_notes, "command", lambda *_: "v2.9.9\nv3.1.2\nv3.1.2-1\nv3.1.3\nv3.1.4\nv3.1.3-beta.1\n")
assert release_notes.previous_tag("v3.1.3") == "v3.1.2-1"
assert release_notes.previous_tag("v3.1.2-2") == "v3.1.2-1"
assert release_notes.previous_tag("v3.0.0") is None
def test_collect_commits_includes_every_page(monkeypatch) -> None:
"""跨页发布记录包含直接提交,比较终点使用固定 SHA 而非可移动标签。"""
calls = []
def fake_command(*args):
"""模拟 GitHub Compare API 的多页响应。"""
calls.append(args)
return json.dumps([{"commits": [make_commit("a", "first")]}, {"commits": [make_commit("b", "second")]}])
monkeypatch.setattr(release_notes, "command", fake_command)
commits = release_notes.collect_commits("owner/repo", "fixed-sha", "v3.1.2")
assert [commit["sha"] for commit in commits] == ["a", "b"]
assert calls == [("gh", "api", "--paginate", "--slurp", "repos/owner/repo/compare/v3.1.2...fixed-sha?per_page=100")]
def test_first_release_collects_entire_history(monkeypatch) -> None:
"""首次发布不凭空选择其它大版本,提交历史按时间正序输出。"""
monkeypatch.setattr(release_notes, "command", lambda *_: json.dumps([[make_commit("b", "new")], [make_commit("a", "old")]]))
commits = release_notes.collect_commits("owner/repo", "fixed-sha", None)
assert [commit["sha"] for commit in commits] == ["a", "b"]
@pytest.mark.parametrize("summary", ["", "- 手工功能说明\n\n### 注意事项\n\n原样保留 `代码` 与 @author", "说明\n\n## 手工子标题\n\n细节"])
def test_rebuild_preserves_manual_summary_and_refreshes_records(summary: str) -> None:
"""重建保留手工摘要,刷新自动章节,正文始终仅包含三个约定章节。"""
old = release_notes.release_body(summary, "old records", "owner/repo", "v3.1.1", "v3.1.2")
preserved = release_notes.manual_summary(old)
new = release_notes.release_body(preserved, "new records", "owner/repo", "v3.1.1", "v3.1.2")
assert preserved == summary
assert "old records" not in new
assert "new records" in new
assert "https://github.com/owner/repo/compare/v3.1.1...v3.1.2" in new
assert "贡献者" not in new
def test_legacy_manual_body_is_preserved_but_generated_body_is_not_summary() -> None:
"""兼容旧手工公告,避免把原有自动分类记录当作本次用户摘要。"""
assert release_notes.manual_summary("手工公告\n- 新功能") == "手工公告\n- 新功能"
assert release_notes.manual_summary("### ✨ 新功能\n\n- generated by @author") == ""
assert release_notes.manual_summary("手工公告\n\n### ✨ 新功能\n- generated") == "手工公告"
@pytest.mark.parametrize("frontend", [False, True])
def test_main_emits_workflow_body_without_evaluating_commit_text(tmp_path, monkeypatch, frontend) -> None:
"""前端仅输出列表,后端保留手工内容;提交中的工作流分隔符不得截断正文。"""
environment = tmp_path / "github.env"
monkeypatch.setenv("GITHUB_ENV", str(environment))
monkeypatch.setenv("RELEASE_BODY", "## 本次更新内容\n\n手工公告\n\n## 完整变更记录\nold")
monkeypatch.setattr(release_notes, "previous_tag", lambda _: "v3.1.2")
monkeypatch.setattr(release_notes, "collect_commits", lambda *_: [make_commit("a" * 40, "fix: $() EOF")])
args = ["release_notes.py", "--repository", "owner/repo", "--tag", "v3.1.3", "--source", "fixed-sha"]
if frontend:
args.append("--changelog-only")
monkeypatch.setattr("sys.argv", args)
release_notes.main()
content = environment.read_text(encoding="utf-8")
delimiter = content.splitlines()[0].split("<<")[1]
assert content.splitlines()[-1] == delimiter
assert "fix: $() EOF by @contributor" in content
assert ("## 本次更新内容" in content) is not frontend
assert ("手工公告" in content) is not frontend
assert ("## 版本对比" in content) is not frontend

View File

@@ -96,7 +96,6 @@ def _run_release_script(
"GITHUB_REPOSITORY": "jxxghp/MoviePilot",
"GITHUB_ENV": str(tmp_path / "github.env"),
"GITHUB_OUTPUT": str(tmp_path / "github.output"),
"CHANGELOG": "generated changelog",
}
)
env.update(extra_env or {})
@@ -270,7 +269,8 @@ def test_release_uses_github_cli_for_tag_and_release_lifecycle() -> None:
("response", "exit_code", "expected_exists", "expected_body"),
[
("HTTP/2.0 200 OK\nHeader: value\n\nmanual body\n", 0, "true", "manual body"),
("HTTP/2.0 404 Not Found\n\n", 1, "false", "generated changelog"),
("HTTP/2.0 200 OK\n\nmanual\nEOF\nMORE=value\n", 0, "true", "manual\nEOF\nMORE=value"),
("HTTP/2.0 404 Not Found\n\n", 1, "false", ""),
],
)
def test_release_query_preserves_existing_body_or_handles_explicit_404(
@@ -280,7 +280,7 @@ def test_release_query_preserves_existing_body_or_handles_explicit_404(
expected_exists: str,
expected_body: str,
) -> None:
"""已有 Release 保留正文,只有明确 404 才使用自动变更记录。"""
"""已有 Release 保留正文,只有明确 404 才将正文初始化为空。"""
script = _steps_by_name(_load_workflow())["Get existing release body"]["run"]
script = script.replace("v${{ env.app_version }}", "v3.0.0")
@@ -291,6 +291,9 @@ def test_release_query_preserves_existing_body_or_handles_explicit_404(
environment = (tmp_path / "github.env").read_text(encoding="utf-8")
assert f"exists={expected_exists}" in output
assert expected_body in environment
delimiter = environment.splitlines()[0].split("<<")[1]
assert delimiter.startswith("RELEASE_NOTES_")
assert environment.splitlines()[-1] == delimiter
def test_release_query_fails_closed_on_non_404_error(tmp_path: Path) -> None: