diff --git a/.github/workflows/append-version-contributor.yml b/.github/workflows/append-version-contributor.yml index ae23f1bd4..6eadb3ecb 100644 --- a/.github/workflows/append-version-contributor.yml +++ b/.github/workflows/append-version-contributor.yml @@ -32,6 +32,11 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} run: git fetch --no-tags origin "pull/${PR_NUMBER}/head:refs/remotes/origin/pr-${PR_NUMBER}" + - name: 设置 Python 环境 + uses: actions/setup-python@v7.0.0 + with: + python-version: '3.12' + - name: 标记更新条目贡献者 id: append env: @@ -41,61 +46,82 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} run: | python - <<'PY' - import json import os import subprocess - from pathlib import Path + import sys - def load_version(ref: str) -> dict: - content = subprocess.check_output( - ["git", "show", f"{ref}:res/version.json"], - text=True, - encoding="utf-8", - ) - return json.loads(content) + sys.path.insert(0, "scripts") + import changelog + + def load(ref: str) -> dict: + """取某个 ref 上的 CHANGELOG.md;取不到或格式不对都当作空,不阻断合并后流程。""" + try: + content = subprocess.check_output( + ["git", "show", f"{ref}:CHANGELOG.md"], + text=True, + encoding="utf-8", + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + print(f"{ref} 上没有 CHANGELOG.md,跳过对比") + return {} + try: + return changelog.parse_changelog(content)[1] + except changelog.ChangelogError as error: + print(f"{ref} 上的 CHANGELOG.md 无法解析,跳过对比:{error}") + return {} - path = Path("res/version.json") - current = json.loads(path.read_text(encoding="utf-8")) - base = load_version(os.environ["PR_BASE_SHA"]) - head = load_version(f"origin/pr-{os.environ['PR_NUMBER']}") + base = load(os.environ["PR_BASE_SHA"]) + head = load(f"origin/pr-{os.environ['PR_NUMBER']}") suffix = f" by [@{os.environ['PR_AUTHOR']}]({os.environ['PR_AUTHOR_URL']})" - additions: set[tuple[str, str, str]] = set() - - for version, info in head.get("version_info", {}).items(): - base_info = base.get("version_info", {}).get(version, {}) - for category, items in info.items(): - if not isinstance(items, list): - continue - base_items = set(base_info.get(category, [])) + + # 本次 PR 新增的条目 = head 有而 base 没有的。按 (版本, 分类, 原文) 三元组记, + # 否则同一句话若在旧版本段里也存在,那一条会被一起补上署名。 + additions = set() + for version, categories in head.items(): + base_categories = base.get(version, {}) + for category, items in categories.items(): + base_items = set(base_categories.get(category, [])) additions.update( (version, category, item) for item in items - if isinstance(item, str) and item not in base_items + if item not in base_items ) + _, current, current_dates = changelog.parse_changelog( + changelog.read_text(changelog.CHANGELOG_PATH) + ) + changed = False - for version, category, item in additions: - items = current.get("version_info", {}).get(version, {}).get(category, []) - if item in items and suffix not in item: - items[items.index(item)] = f"{item}{suffix}" - changed = True + for version, categories in current.items(): + for category, items in categories.items(): + for index, item in enumerate(items): + if (version, category, item) in additions and suffix not in item: + items[index] = f"{item}{suffix}" + changed = True if changed: - path.write_text( - json.dumps(current, ensure_ascii=False, indent=4) + "\n", - encoding="utf-8", + changelog.write_text( + changelog.CHANGELOG_PATH, + changelog.render_changelog(current, current_dates), ) - with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: output.write(f"changed={str(changed).lower()}\n") PY + # 署名写进 CHANGELOG.md 正文后,res/version.json 要跟着重算。 + - name: 重新生成版本信息 + if: steps.append.outputs.changed == 'true' + run: python scripts/changelog.py sync + - name: 提交贡献者信息 if: steps.append.outputs.changed == 'true' run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add res/version.json + # sync 只有在 dev 本来就不一致时才会动后四个文件,一并纳入避免 rebase 撞未暂存改动。 + git add CHANGELOG.md res/version.json frontend/package.json app/core/config.py pyproject.toml uv.lock git commit -m "chore(version): mark PR entry contributors" git pull --rebase origin dev git push origin HEAD:dev diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml index 934fce702..fc84f883d 100644 --- a/.github/workflows/build-app.yml +++ b/.github/workflows/build-app.yml @@ -33,7 +33,7 @@ env: # latest:Runtime 有自己的发布节奏,本仓库的改动不应该在没有联调的情况下自动带出一个新 # Runtime。Runtime 不自更新,只随这里构建的安装包整体升级;本仓库如有依赖 Runtime 新行为 # 的改动(T13 系列),必须等 Runtime 一侧先发布对应版本,再手动把这个版本号提上去。 - RUNTIME_VERSION: v0.1.3 + RUNTIME_VERSION: v0.1.4 jobs: diff --git a/.github/workflows/check-changelog.yml b/.github/workflows/check-changelog.yml new file mode 100644 index 000000000..08a6d009c --- /dev/null +++ b/.github/workflows/check-changelog.yml @@ -0,0 +1,63 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +# Contact: DLmaster_361@163.com + +name: 检查更新日志 + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: read + +jobs: + check-changelog: + name: 检查 CHANGELOG.md 与生成物一致 + runs-on: ubuntu-latest + + steps: + - name: 检出代码 + uses: actions/checkout@v7.0.1 + + - name: 设置 Python 环境 + uses: actions/setup-python@v7.0.0 + with: + python-version: '3.12' + + # CHANGELOG.md 是唯一手写来源,res/version.json 与四处版本号都由它生成。 + # 这一步同时校验更新日志格式、五处版本号一致、生成物是最新的。 + - name: 检查更新日志格式与版本号一致性 + run: python scripts/changelog.py check + + - name: 检查 PR 文件列表 + uses: actions/github-script@v9.0.0 + with: + script: | + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + per_page: 100, + }); + + if (!files.some(file => file.filename === 'CHANGELOG.md')) { + core.setFailed('每个 PR 都必须包含对 CHANGELOG.md 的更改。'); + } diff --git a/.github/workflows/check-version-json.yml b/.github/workflows/check-version-json.yml deleted file mode 100644 index b30d1ab05..000000000 --- a/.github/workflows/check-version-json.yml +++ /dev/null @@ -1,135 +0,0 @@ -# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software -# Copyright © 2025-2026 AUTO-MAS Team - -# This file is part of AUTO-MAS. - -# AUTO-MAS is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of -# the License, or (at your option) any later version. - -# AUTO-MAS is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty -# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See -# the GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with AUTO-MAS. If not, see . - -# Contact: DLmaster_361@163.com - -name: 检查版本信息 - -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - -permissions: - contents: read - pull-requests: read - -jobs: - check-version-json: - name: 检查 version.json 变更 - runs-on: ubuntu-latest - - steps: - - name: 检出代码 - uses: actions/checkout@v7.0.1 - - - name: 设置 Python 环境 - uses: actions/setup-python@v7.0.0 - with: - python-version: '3.12' - - - name: 检查 version.json 语法 - run: python -m json.tool res/version.json > /dev/null - - - name: 检查版本号一致性 - run: | - python -m pip install --quiet packaging==25.0 - python - <<'PY' - import ast - import json - import tomllib - from pathlib import Path - - from packaging.version import Version - - with Path("res/version.json").open(encoding="utf-8") as file: - version_json = json.load(file) - - with Path("frontend/package.json").open(encoding="utf-8") as file: - package_json = json.load(file) - - config_path = Path("app/core/config.py") - config_module = ast.parse( - config_path.read_text(encoding="utf-8"), - filename=str(config_path), - ) - - app_config_version = None - for node in config_module.body: - if not isinstance(node, ast.ClassDef) or node.name != "AppConfig": - continue - - for statement in node.body: - if not isinstance(statement, ast.Assign): - continue - if any( - isinstance(target, ast.Name) and target.id == "VERSION" - for target in statement.targets - ): - app_config_version = ast.literal_eval(statement.value) - break - break - - if app_config_version is None: - raise SystemExit("未在 app/core/config.py 的 AppConfig 中找到 VERSION") - - with Path("pyproject.toml").open("rb") as file: - pyproject_toml = tomllib.load(file) - pyproject_version = pyproject_toml["project"]["version"] - - versions = { - "res/version.json": version_json["version"], - "frontend/package.json": package_json["version"], - "app/core/config.py": app_config_version, - "pyproject.toml": pyproject_version, - } - - # 前三处要求逐字相同;pyproject.toml 走 PEP 440(如 5.5.0b3), - # 去掉前导 v 后按 packaging 规范化比较(5.5.0-beta.3 与 5.5.0b3 规范化后相等)。 - literal_versions = { - path: value for path, value in versions.items() if path != "pyproject.toml" - } - is_consistent = len(set(literal_versions.values())) == 1 - if is_consistent: - shared_version = next(iter(literal_versions.values())) - is_consistent = Version(shared_version.removeprefix("v")) == Version( - pyproject_version - ) - - if not is_consistent: - details = "\n".join( - f"- {path}: {version}" for path, version in versions.items() - ) - raise SystemExit(f"版本号不一致:\n{details}") - - print(f"版本号一致: {app_config_version}") - PY - - - name: 检查 PR 文件列表 - uses: actions/github-script@v9.0.0 - with: - script: | - const files = await github.paginate(github.rest.pulls.listFiles, { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - per_page: 100, - }); - - if (!files.some(file => file.filename === 'res/version.json')) { - core.setFailed('每个 PR 都必须包含对 res/version.json 的更改。'); - } diff --git a/scripts/build-local-package.ps1 b/scripts/build-local-package.ps1 index bf443e5d9..be1e4243a 100644 --- a/scripts/build-local-package.ps1 +++ b/scripts/build-local-package.ps1 @@ -54,49 +54,24 @@ if (-not (Get-Command yarn -ErrorAction SilentlyContinue)) { throw "未找到 Yarn,请先执行:corepack prepare yarn@4.9.1 --activate" } -# 第一步:确认所有版本来源一致,避免打出版本信息互相冲突的安装包。 -$versionConfig = Get-Content -LiteralPath $versionFile -Raw | ConvertFrom-Json -$frontendPackage = Get-Content -LiteralPath $frontendPackageFile -Raw | ConvertFrom-Json -$appVersion = [string]$versionConfig.version - -if ($appVersion -notmatch '^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$') { - throw "res/version.json 中的版本格式无效:$appVersion" -} -if ([string]$frontendPackage.version -ne $appVersion) { - throw "frontend/package.json 版本不一致:$($frontendPackage.version),预期 $appVersion" +# 版本一致性由 scripts/changelog.py 判定,这里需要一个可用的 Python。 +$venvPython = Join-Path $repoRoot ".venv\Scripts\python.exe" +$pythonExe = if (Test-Path -LiteralPath $venvPython) { $venvPython } else { "python" } +if (-not (Get-Command $pythonExe -ErrorAction SilentlyContinue)) { + throw "未找到 Python,请先安装项目要求的 Python 3.12 环境,或在仓库根创建 .venv。" } -$backendConfigText = Get-Content -LiteralPath $backendConfigFile -Raw -$backendVersionMatch = [regex]::Match( - $backendConfigText, - '(?m)^\s*VERSION\s*=\s*"(?v[^"]+)"' -) -if (-not $backendVersionMatch.Success -or $backendVersionMatch.Groups['version'].Value -ne $appVersion) { - throw "app/core/config.py 版本与 $appVersion 不一致。" +# 第一步:确认版本信息与 CHANGELOG.md 一致,避免打出版本信息互相冲突的安装包。 +# 版本号的唯一手写来源是 CHANGELOG.md,res/version.json 等五处都由 scripts/changelog.py +# 生成;这里只调用它,不重复实现规则。 +& $pythonExe (Join-Path $repoRoot "scripts\changelog.py") check +if ($LASTEXITCODE -ne 0) { + throw "版本信息与 CHANGELOG.md 不一致,请先运行:python scripts/changelog.py sync" } +$versionConfig = Get-Content -LiteralPath $versionFile -Raw | ConvertFrom-Json +$appVersion = [string]$versionConfig.version $pythonVersion = $appVersion.Substring(1) -$pyprojectText = Get-Content -LiteralPath $pyprojectFile -Raw -$pyprojectVersionMatch = [regex]::Match( - $pyprojectText, - '(?m)^version\s*=\s*"(?[^"]+)"' -) -if (-not $pyprojectVersionMatch.Success -or $pyprojectVersionMatch.Groups['version'].Value -ne $pythonVersion) { - throw "pyproject.toml 版本与 $pythonVersion 不一致。" -} - -$expectedLockVersion = $pythonVersion ` - -replace '-alpha\.', 'a' ` - -replace '-beta\.', 'b' ` - -replace '-rc\.', 'rc' -$uvLockText = Get-Content -LiteralPath $uvLockFile -Raw -$uvVersionMatch = [regex]::Match( - $uvLockText, - '(?ms)^\[\[package\]\]\r?\nname = "auto-mas"\r?\nversion = "(?[^"]+)"' -) -if (-not $uvVersionMatch.Success -or $uvVersionMatch.Groups['version'].Value -ne $expectedLockVersion) { - throw "uv.lock 中 auto-mas 的版本与 $expectedLockVersion 不一致,请先运行 uv lock。" -} $workflowText = Get-Content -LiteralPath $buildWorkflowFile -Raw $runtimeVersionMatch = [regex]::Match( diff --git a/scripts/changelog.py b/scripts/changelog.py new file mode 100644 index 000000000..93b0bd3fa --- /dev/null +++ b/scripts/changelog.py @@ -0,0 +1,449 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +# Contact: DLmaster_361@163.com + + +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""更新日志与版本号的唯一入口。 + +`CHANGELOG.md` 遵循 Keep a Changelog 1.1.0,是**唯一由人手写**的来源:文件里第一个 +`## [vX.Y.Z] - 未发布` 标题就是当前尚未发布的版本号,它下面的条目就是这一版的更新日志。 +其余五处版本号与 `res/version.json` 全部由本脚本从它生成,不要手改: + +- `res/version.json` —— 整份生成(前端编译期注入、发布 CI 生成 Release 正文都读它) +- `frontend/package.json` +- `app/core/config.py` +- `pyproject.toml` —— PEP 440 写法,如 5.5.0b3 +- `uv.lock` —— 其中 auto-mas 包自身的版本,同样是 PEP 440 写法 + +用法:: + + python scripts/changelog.py sync # 从 CHANGELOG.md 同步到上述各处 + python scripts/changelog.py check # 校验各处已同步(CI 与本地打包脚本用) + python scripts/changelog.py current # 打印当前版本号 + +`sync` 也会把 `CHANGELOG.md` 自身规范化:重写文件头的说明、把分类按固定顺序排列、 +重新生成底部的版本对比链接。所以贡献者只要把条目写进对的分类下就行。 +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Dict, List, Tuple + +REPO_ROOT = Path(__file__).resolve().parent.parent +REPO_URL = "https://github.com/AUTO-MAS-Project/AUTO-MAS" +# 未发布版本的对比链接指向开发分支 +DEVELOPMENT_BRANCH = "dev" + +CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md" +VERSION_JSON_PATH = REPO_ROOT / "res" / "version.json" +PACKAGE_JSON_PATH = REPO_ROOT / "frontend" / "package.json" +APP_CONFIG_PATH = REPO_ROOT / "app" / "core" / "config.py" +PYPROJECT_PATH = REPO_ROOT / "pyproject.toml" +UV_LOCK_PATH = REPO_ROOT / "uv.lock" + +UNRELEASED = "未发布" + +# 分类的固定顺序。中间六类是 Keep a Changelog 的标准分类(用中文标题,因为条目本身是 +# 中文、而且这些标题会直接显示在应用内的更新提示里);首尾三类是本项目的扩展。 +# 这不是白名单——表外的新分类照常保留,只是排在这些之后。 +CATEGORY_ORDER = [ + "破坏性变更", # 本项目扩展:需要用户动手确认的改动,置顶最醒目 + "本次亮点", # 本项目扩展:这一版最值得看的三五条 + "新增", # Added + "变更", # Changed + "弃用", # Deprecated + "移除", # Removed + "修复", # Fixed + "安全", # Security + "开发流程", # 本项目扩展:只影响贡献者、不影响用户的改动 +] + +VERSION_PATTERN = re.compile(r"^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$") +PRE_RELEASE_PATTERN = re.compile(r"^v(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)\.(\d+))?$") +PRE_RELEASE_ABBR = {"alpha": "a", "beta": "b", "rc": "rc"} + +RELEASE_HEADING = re.compile( + rf"^## \[(?P[^\]]+)\] - (?P{UNRELEASED}|\d{{4}}-\d{{2}}-\d{{2}})$" +) +# 底部的版本对比链接,由 render_changelog 重新生成,解析时跳过 +LINK_DEFINITION = re.compile(r"^\[[^\]]+\]:\s+\S+$") + +CHANGELOG_PREAMBLE = """# 更新日志 + +本项目所有值得注意的变更都记录在此文件中。 + +格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), +版本号遵循[语义化版本](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。 + + +""" + + +class ChangelogError(Exception): + """CHANGELOG.md 不符合约定的格式。""" + + +Sections = Dict[str, Dict[str, List[str]]] +Dates = Dict[str, str] + + +def read_text(path: Path) -> str: + """用 utf-8-sig 读,顺手吃掉记事本保存出来的 BOM,免得报成「无法识别的内容」。""" + + return path.read_text(encoding="utf-8-sig") + + +def write_text(path: Path, content: str) -> None: + """始终以 LF 写出,避免 Windows 上写成 CRLF 与 .gitattributes 冲突。""" + + path.write_text(content, encoding="utf-8", newline="\n") + + +def parse_changelog(text: str) -> Tuple[str, Sections, Dates]: + """把 CHANGELOG.md 解析成 (当前版本号, {版本: {分类: [条目]}}, {版本: 日期})。 + + 第一个 `## [...]` 之前的内容是文件头说明,整段由 render_changelog 重新生成, + 这里一律跳过,所以文件头里可以写任意散文与注释。 + """ + + sections: Sections = {} + dates: Dates = {} + current_version: str | None = None + current_category: str | None = None + seen_release = False + + for number, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.rstrip() + stripped = line.strip() + + is_release_heading = stripped.startswith("## ") + if not seen_release and not is_release_heading: + continue + + if is_release_heading: + seen_release = True + matched = RELEASE_HEADING.match(stripped) + if matched is None: + raise ChangelogError( + f"第 {number} 行:版本标题必须形如 " + f"`## [v5.5.0-beta.3] - 2026-08-31` 或 `## [v5.5.0-beta.3] - {UNRELEASED}`," + f"实际是 {stripped!r}" + ) + version = matched.group("version") + date = matched.group("date") + if not VERSION_PATTERN.match(version): + raise ChangelogError( + f"第 {number} 行:版本号必须形如 `v5.5.0-beta.3`,实际是 {version!r}" + ) + if version in sections: + raise ChangelogError(f"第 {number} 行:版本 {version} 重复出现") + if date == UNRELEASED and sections: + raise ChangelogError( + f"第 {number} 行:只有文件顶部的第一个版本可以标 {UNRELEASED}" + ) + sections[version] = {} + dates[version] = date + current_version = version + current_category = None + continue + + if not stripped: + continue + + # 底部的版本对比链接由 render 重新生成,解析时忽略 + if LINK_DEFINITION.match(stripped): + continue + + if stripped.startswith("