From ef2c8c1a8eb7b29d385d92f9cabefcab8206b978 Mon Sep 17 00:00:00 2001 From: azmiao <2362020227@qq.com> Date: Wed, 4 Nov 2026 11:33:25 +0800 Subject: [PATCH] ci: automate IDL releases and changelogs --- .github/workflows/ci.yml | 30 ++- .github/workflows/release-assets.yml | 37 ---- scripts/generate_changelog.py | 222 ++++++++++++++++++++++ scripts/git-nickname-username.json | 8 + scripts/git-nickname-username.schema.json | 26 +++ 5 files changed, 285 insertions(+), 38 deletions(-) delete mode 100644 .github/workflows/release-assets.yml create mode 100644 scripts/generate_changelog.py create mode 100644 scripts/git-nickname-username.json create mode 100644 scripts/git-nickname-username.schema.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b782d8..254d03e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: branches: [master] - tags: ['v*'] + tags: ['v*.*.*'] pull_request: workflow_dispatch: @@ -162,3 +162,31 @@ jobs: repository="$(printf '%s' "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')" printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin helm push "${RUNNER_TEMP}/helm-packages/alisten-${chart_version}.tgz" "oci://ghcr.io/${repository}/charts" + + release: + needs: helm-release + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + - name: Generate release notes + shell: bash + run: | + set -euo pipefail + python scripts/generate_changelog.py --tag "$GITHUB_REF_NAME" --output release-notes.md + test -s release-notes.md + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + body_path: release-notes.md + files: protocol/idl.json + fail_on_unmatched_files: true diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml deleted file mode 100644 index f6b8ab2..0000000 --- a/.github/workflows/release-assets.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Release assets - -on: - release: - types: [published] - workflow_dispatch: - inputs: - tag: - description: 要上传附件的 Release tag - required: true - type: string - -concurrency: - group: release-assets-${{ github.event.release.tag_name || inputs.tag }} - -permissions: - contents: read - -jobs: - idl: - runs-on: ubuntu-latest - permissions: - contents: write - env: - TAG: ${{ github.event.release.tag_name || inputs.tag }} - GH_REPO: ${{ github.repository }} - GH_TOKEN: ${{ github.token }} - steps: - - uses: actions/checkout@v7 - with: - ref: refs/tags/${{ env.TAG }} - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - - run: go run ./cmd/protocolgen -check - - run: gh release upload "$TAG" protocol/idl.json --clobber - - run: gh release download "$TAG" --pattern idl.json --output - | cmp - protocol/idl.json diff --git a/scripts/generate_changelog.py b/scripts/generate_changelog.py new file mode 100644 index 0000000..37c6c3e --- /dev/null +++ b/scripts/generate_changelog.py @@ -0,0 +1,222 @@ +"""根据正式版本标签生成项目 Changelog。""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path + +VERSION_TAG_PATTERN = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") +# type 与 emoji 之间的空格可有可无(\s*),同时兼容 "feat ✨:" 旧格式与 "feat✨:" 新格式 +COMMIT_PATTERN = re.compile( + r"^(?P[A-Za-z]+)(?:\s*[^\w\s:(]+)?" + r"(?:\((?P[^)]+)\))?\s*:\s*(?P.+)$" +) +GITHUB_NOREPLY_PATTERN = re.compile( + r"^(?:\d+\+)?(?P[^@]+)@users\.noreply\.github\.com$", + re.IGNORECASE, +) +DEFAULT_NICKNAME_MAP_PATH = Path(__file__).with_name("git-nickname-username.json") + +CATEGORY_BY_TYPE = { + "feat": "✨ Features", + "fix": "🐛 Bug Fixes", + "patch": "🐛 Bug Fixes", + "perf": "🚀 Performance Improvements", + "refactor": "🎨 Code Refactoring", + "docs": "📚 Documentation", + "deps": "🧩 Dependency Updates", + "build": "🧩 Dependency Updates", + "test": "🧪 Tests", + "ci": "⚙️ Continuous Integration", + "chore": "🧹 Maintenance", + "style": "🧹 Maintenance", + "format": "🧹 Maintenance", + "config": "🧹 Maintenance", +} +CATEGORY_ORDER = tuple(dict.fromkeys(CATEGORY_BY_TYPE.values())) + ("Other Changes",) + + +@dataclass(frozen=True) +class Commit: + subject: str + author_name: str + author_email: str + category: str + + +def run_git(*args: str) -> str: + result = subprocess.run( + ["git", *args], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + ) + return result.stdout + + +def parse_version_tag(tag: str) -> tuple[int, int, int] | None: + match = VERSION_TAG_PATTERN.fullmatch(tag) + if not match: + return None + return int(match.group(1)), int(match.group(2)), int(match.group(3)) + + +def get_release_tags() -> list[str]: + tags = [line.strip() for line in run_git("tag", "--list").splitlines()] + versions = [ + (version, tag) + for tag in tags + if (version := parse_version_tag(tag)) is not None + ] + return [tag for _, tag in sorted(versions, reverse=True)] + + +def get_previous_tag(tag: str, tags: list[str]) -> str | None: + index = tags.index(tag) + return tags[index + 1] if index + 1 < len(tags) else None + + +def read_commit_records(tag: str, previous_tag: str | None) -> list[str]: + revision = f"{previous_tag}..{tag}" if previous_tag else tag + output = run_git( + "log", + "--no-merges", + "--format=%an%x1f%ae%x1f%s%x1e", + revision, + ) + return [record.strip() for record in output.split("\x1e") if record.strip()] + + +def load_nickname_map(path: Path = DEFAULT_NICKNAME_MAP_PATH) -> dict[str, str]: + if not path.exists(): + return {} + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"Git 昵称映射配置必须是 JSON 对象:{path}") + return { + name: username + for name, username in data.items() + if not name.startswith("$") and isinstance(username, str) + } + + +def format_author(name: str, email: str, nickname_map: dict[str, str]) -> str: + mapped_username = nickname_map.get(name.strip()) + if mapped_username: + return f"@{mapped_username}" + match = GITHUB_NOREPLY_PATTERN.fullmatch(email.strip()) + return f"@{match.group('username')}" if match else name.strip() + + +def parse_commit_record(record: str) -> Commit: + fields = record.split("\x1f", maxsplit=2) + if len(fields) != 3: + raise ValueError("Git 提交记录格式损坏") + + author_name, author_email, raw_subject = (field.strip() for field in fields) + match = COMMIT_PATTERN.fullmatch(raw_subject) + if not match: + return Commit(raw_subject, author_name, author_email, "Other Changes") + + commit_type = match.group("type").lower() + scope = match.group("scope") + message = match.group("message").strip() + subject = f"*({scope})* {message}" if scope else message + category = CATEGORY_BY_TYPE.get(commit_type, "Other Changes") + return Commit(subject, author_name, author_email, category) + + +def render_version(tag: str, tags: list[str], nickname_map: dict[str, str]) -> str: + if parse_version_tag(tag) is None: + raise ValueError(f"不是正式版本标签:{tag}") + if tag not in tags: + raise ValueError(f"正式版本标签不存在:{tag}") + + run_git("rev-parse", "--verify", f"refs/tags/{tag}") + previous_tag = get_previous_tag(tag, tags) + commits = [parse_commit_record(record) for record in read_commit_records(tag, previous_tag)] + grouped: dict[str, list[Commit]] = defaultdict(list) + for commit in commits: + grouped[commit.category].append(commit) + + date = run_git("log", "-1", "--format=%cs", tag).strip() + lines = [f"## {tag.removeprefix('v')} ({date})"] + for category in CATEGORY_ORDER: + category_commits = grouped.get(category) + if not category_commits: + continue + lines.extend(("", f"### {category}", "")) + lines.extend( + f"- {commit.subject} {format_author(commit.author_name, commit.author_email, nickname_map)}" + for commit in category_commits + ) + return "\n".join(lines) + "\n" + + +def render_unreleased(nickname_map: dict[str, str]) -> str: + """无 tag 时,将 HEAD 视为未发布版本,范围为全部提交。""" + commits = [parse_commit_record(record) for record in read_commit_records("HEAD", None)] + grouped: dict[str, list[Commit]] = defaultdict(list) + for commit in commits: + grouped[commit.category].append(commit) + + lines = ["## 未发布"] + for category in CATEGORY_ORDER: + category_commits = grouped.get(category) + if not category_commits: + continue + lines.extend(("", f"### {category}", "")) + lines.extend( + f"- {commit.subject} {format_author(commit.author_name, commit.author_email, nickname_map)}" + for commit in category_commits + ) + return "\n".join(lines) + "\n" + + +def render_changelog(tags: list[str], nickname_map: dict[str, str]) -> str: + if not tags: + return render_unreleased(nickname_map) + sections = [render_version(tag, tags, nickname_map).rstrip() for tag in tags] + return "# 更新日志\n\n" + "\n\n".join(sections) + "\n" + + +def parse_args(argv: list[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="根据正式版本标签生成项目 Changelog") + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--tag", help="只生成指定正式版本的发布说明") + mode.add_argument("--all", action="store_true", help="生成全部正式版本的 Changelog") + parser.add_argument("--output", type=Path, help="将结果写入指定文件") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + try: + tags = get_release_tags() + nickname_map = load_nickname_map() + content = ( + render_changelog(tags, nickname_map) + if args.all + else render_version(args.tag, tags, nickname_map) + ) + if args.output: + args.output.write_text(content, encoding="utf-8", newline="\n") + else: + sys.stdout.buffer.write(content.encode("utf-8")) + return 0 + except (subprocess.CalledProcessError, ValueError, OSError) as error: + detail = error.stderr.strip() if isinstance(error, subprocess.CalledProcessError) else str(error) + print(f"生成 Changelog 失败:{detail}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/git-nickname-username.json b/scripts/git-nickname-username.json new file mode 100644 index 0000000..9ae4166 --- /dev/null +++ b/scripts/git-nickname-username.json @@ -0,0 +1,8 @@ +{ + "$schema": "./git-nickname-username.schema.json", + "$comment": "Git 昵称到 GitHub 用户名的映射,用于 generate_changelog.py 脚本", + "azmiao": "azmiao", + "AZMIAO": "azmiao", + "DiheChen": "DiheChen", + "dependabot[bot]": "dependabot[bot]" +} diff --git a/scripts/git-nickname-username.schema.json b/scripts/git-nickname-username.schema.json new file mode 100644 index 0000000..8138b73 --- /dev/null +++ b/scripts/git-nickname-username.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Git Nickname to GitHub Username Mapping", + "description": "Git 提交昵称到 GitHub 用户名的映射配置,用于 generate_changelog.py 正确生成 @username 链接", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema 引用" + }, + "$comment": { + "type": "string", + "description": "配置文件说明" + } + }, + "additionalProperties": { + "type": "string", + "description": "GitHub 用户名", + "pattern": "^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}(\\[bot\\])?$" + }, + "examples": [ + { + "azmiao": "azmiao" + } + ] +}