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("