From 63e30e453bc52790f722031d896bc9e1dbc9ae66 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 10 Oct 2026 08:37:30 +0800 Subject: [PATCH] ci: preserve manual release summaries and raw changelog --- .github/workflows/build-v3.yml | 128 +++--------------- .../12-collaboration-and-distribution.md | 16 ++- scripts/release_notes.py | 112 +++++++++++++++ tests/test_release_notes.py | 111 +++++++++++++++ tests/test_release_supply_chain.py | 9 +- 5 files changed, 262 insertions(+), 114 deletions(-) create mode 100644 scripts/release_notes.py create mode 100644 tests/test_release_notes.py diff --git a/.github/workflows/build-v3.yml b/.github/workflows/build-v3.yml index cb8115c06..cfeffe6f9 100644 --- a/.github/workflows/build-v3.yml +++ b/.github/workflows/build-v3.yml @@ -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<> $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<> "$GITHUB_ENV" - else - { - echo "RELEASE_BODY<> "$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: diff --git a/docs/rules/12-collaboration-and-distribution.md b/docs/rules/12-collaboration-and-distribution.md index c27bd5168..504746c17 100644 --- a/docs/rules/12-collaboration-and-distribution.md +++ b/docs/rules/12-collaboration-and-distribution.md @@ -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: diff --git a/scripts/release_notes.py b/scripts/release_notes.py new file mode 100644 index 000000000..5d1a1fb1d --- /dev/null +++ b/scripts/release_notes.py @@ -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() diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py new file mode 100644 index 000000000..a20507041 --- /dev/null +++ b/tests/test_release_notes.py @@ -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 diff --git a/tests/test_release_supply_chain.py b/tests/test_release_supply_chain.py index 1eb52817d..8fe82391d 100644 --- a/tests/test_release_supply_chain.py +++ b/tests/test_release_supply_chain.py @@ -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: