From e5c967ce8d911fd82c6595fcd51b55fb817f8d3b Mon Sep 17 00:00:00 2001 From: ThirteenLLB <1340481713@qq.com> Date: Sun, 22 Feb 2026 15:01:26 +0800 Subject: [PATCH 1/4] feat(release): auto-update version files from .release.yml config Add update-versions.py script and integrate it into centralized-release workflow. Repos with a .release.yml config will have their version files (Cargo.toml, package.json, tauri.conf.json, etc.) automatically updated when creating release PRs. Resolve m-6822381131 Co-Authored-By: Claude Opus 4.6 --- .github/workflows/centralized-release.yml | 57 +++++- scripts/update-versions.py | 202 ++++++++++++++++++++++ 2 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 scripts/update-versions.py diff --git a/.github/workflows/centralized-release.yml b/.github/workflows/centralized-release.yml index 2537cf7..e3d8cd4 100644 --- a/.github/workflows/centralized-release.yml +++ b/.github/workflows/centralized-release.yml @@ -127,6 +127,22 @@ jobs: --version ${{ matrix.version }} \ $date_arg + - name: Update version files + if: ${{ !inputs.dry_run }} + working-directory: target-repo + env: + VERSION: ${{ matrix.version }} + run: | + if [ -f ".release.yml" ]; then + echo "📦 发现 .release.yml,更新版本号文件..." + pip install pyyaml -q + python3 ../.github-repo/scripts/update-versions.py \ + --config .release.yml \ + --version "$VERSION" + else + echo "ℹ️ 未发现 .release.yml,跳过版本号更新" + fi + - name: Create release branch if: ${{ !inputs.dry_run }} working-directory: target-repo @@ -142,9 +158,20 @@ jobs: if [ -n "${{ matrix.public_changelog_path }}" ]; then git add "${{ matrix.public_changelog_path }}" fi + if [ -f ".release.yml" ]; then + python3 -c " + import yaml + with open('.release.yml') as f: + config = yaml.safe_load(f) + for entry in config.get('version_files', []): + print(entry['path']) + " | while read -r vfile; do + [ -f "$vfile" ] && git add "$vfile" && echo " 📦 Staged: $vfile" + done + fi git commit -m "release: v${VERSION} - Update CHANGELOG.md for v${VERSION} release" + Update CHANGELOG.md and version files for v${VERSION} release" git push origin "$branch_name" echo "✅ 已推送分支: $branch_name" @@ -166,12 +193,26 @@ jobs: date_info="- **计划发布日期**: ${RELEASE_DATE}" fi + # 生成版本文件列表 + version_files_info="" + if [ -f ".release.yml" ]; then + vfiles=$(python3 -c " + import yaml + with open('.release.yml') as f: + config = yaml.safe_load(f) + paths = [e['path'] for e in config.get('version_files', [])] + print(', '.join(f'\`{p}\`' for p in paths)) + ") + version_files_info=" + - 更新版本号文件: ${vfiles}" + fi + pr_body="## Release v${VERSION} 此 PR 由集中式 Release 自动化流程创建。 ### 变更内容 - - 更新 CHANGELOG.md,将 Unreleased 替换为 v${VERSION} + - 更新 CHANGELOG.md,将 Unreleased 替换为 v${VERSION}${version_files_info} ${date_info} ### 后续步骤 @@ -194,9 +235,21 @@ jobs: - name: Dry run summary if: ${{ inputs.dry_run }} + working-directory: target-repo + env: + VERSION: ${{ matrix.version }} run: | echo "🔍 Dry run 模式,不创建 PR" echo "✅ CHANGELOG 更新成功" + if [ -f ".release.yml" ]; then + echo "" + echo "📦 版本号文件更新预览:" + pip install pyyaml -q 2>/dev/null + python3 ../.github-repo/scripts/update-versions.py \ + --config .release.yml \ + --version "$VERSION" \ + --dry-run + fi echo "" echo "如需实际创建 PR,请取消勾选 dry_run 选项" diff --git a/scripts/update-versions.py b/scripts/update-versions.py new file mode 100644 index 0000000..702326f --- /dev/null +++ b/scripts/update-versions.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +""" +更新版本号文件,根据 .release.yml 配置自动替换版本字符串 + +用法: python3 update-versions.py --config .release.yml --version 0.6.0 [--dry-run] +退出码: 0=成功或无配置文件, 1=错误 +""" +import re +import argparse +import sys +from pathlib import Path + +import yaml + + +def validate_version(version): + """校验语义化版本号格式""" + pattern = r'^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?(?:\+[a-zA-Z0-9.]+)?$' + return bool(re.match(pattern, version)) + + +def validate_pattern(pattern): + """校验正则表达式合法性且恰好有 2 个捕获组""" + try: + compiled = re.compile(pattern, re.MULTILINE) + except re.error as e: + return False, f"正则表达式不合法: {e}" + + if compiled.groups != 2: + return False, f"需要恰好 2 个捕获组,实际有 {compiled.groups} 个" + + return True, "" + + +def validate_path(path_str): + """校验路径为相对路径且不含 ..""" + p = Path(path_str) + if p.is_absolute(): + return False, "路径必须为相对路径" + if '..' in p.parts: + return False, "路径不能包含 .." + return True, "" + + +def update_versions(config_path, version, dry_run=False): + """ + 读取 .release.yml 配置并更新版本号文件 + + Args: + config_path: .release.yml 路径 + version: 新版本号 (如 0.6.0) + dry_run: 仅预览,不实际修改文件 + + Returns: + {"success": bool, "message": str, "updated": list} + """ + config_file = Path(config_path) + if not config_file.exists(): + return { + "success": True, + "message": "未发现配置文件,跳过版本号更新", + "updated": [], + } + + # 校验版本号 + if not validate_version(version): + return { + "success": False, + "message": f"版本号格式不合法: {version} (需要语义化版本,如 1.0.0)", + "updated": [], + } + + # 加载配置 + try: + with open(config_file, encoding='utf-8') as f: + config = yaml.safe_load(f) + except Exception as e: + return { + "success": False, + "message": f"读取配置文件失败: {e}", + "updated": [], + } + + if not config or 'version_files' not in config: + return { + "success": False, + "message": "配置文件缺少 version_files 字段", + "updated": [], + } + + version_files = config['version_files'] + if not isinstance(version_files, list) or len(version_files) == 0: + return { + "success": False, + "message": "version_files 必须为非空列表", + "updated": [], + } + + # 校验所有规则 + for entry in version_files: + if 'path' not in entry or 'pattern' not in entry: + return { + "success": False, + "message": f"每条规则必须包含 path 和 pattern 字段: {entry}", + "updated": [], + } + + path_ok, path_err = validate_path(entry['path']) + if not path_ok: + return { + "success": False, + "message": f"路径校验失败 ({entry['path']}): {path_err}", + "updated": [], + } + + pattern_ok, pattern_err = validate_pattern(entry['pattern']) + if not pattern_ok: + return { + "success": False, + "message": f"正则校验失败 ({entry['path']}): {pattern_err}", + "updated": [], + } + + # 执行替换 + updated = [] + config_dir = config_file.parent + + for entry in version_files: + file_path = config_dir / entry['path'] + pattern = entry['pattern'] + + if not file_path.exists(): + return { + "success": False, + "message": f"文件不存在: {entry['path']}", + "updated": updated, + } + + try: + content = file_path.read_text(encoding='utf-8') + except Exception as e: + return { + "success": False, + "message": f"读取文件失败 ({entry['path']}): {e}", + "updated": updated, + } + + replacement = rf'\g<1>{version}\g<2>' + new_content, count = re.subn( + pattern, replacement, content, count=1, flags=re.MULTILINE + ) + + if count == 0: + return { + "success": False, + "message": f"未匹配到版本号 ({entry['path']}): pattern={pattern}", + "updated": updated, + } + + if dry_run: + # 找到被替换的行用于预览 + match = re.search(pattern, content, re.MULTILINE) + old_line = match.group(0).strip() if match else "?" + new_match = re.search(pattern.replace('[^"]+', re.escape(version)), new_content, re.MULTILINE) + print(f" 📦 [DRY RUN] {entry['path']}: {old_line} → version={version}") + else: + try: + file_path.write_text(new_content, encoding='utf-8') + except Exception as e: + return { + "success": False, + "message": f"写入文件失败 ({entry['path']}): {e}", + "updated": updated, + } + print(f" 📦 已更新: {entry['path']} → {version}") + + updated.append(entry['path']) + + return { + "success": True, + "message": f"成功更新 {len(updated)} 个版本号文件", + "updated": updated, + } + + +if __name__ == '__main__': + parser = argparse.ArgumentParser( + description='更新版本号文件,根据 .release.yml 配置自动替换版本字符串' + ) + parser.add_argument('--config', required=True, help='.release.yml 配置文件路径') + parser.add_argument('--version', required=True, help='版本号 (如 0.6.0)') + parser.add_argument('--dry-run', action='store_true', help='仅预览,不实际修改文件') + + args = parser.parse_args() + result = update_versions(args.config, args.version, args.dry_run) + + if result['success']: + print(f"✅ {result['message']}") + sys.exit(0) + else: + print(f"❌ {result['message']}") + sys.exit(1) From 7532583037d11e6bb54f089dc96ff1cced60226c Mon Sep 17 00:00:00 2001 From: ThirteenLLB <1340481713@qq.com> Date: Wed, 25 Feb 2026 11:35:43 +0800 Subject: [PATCH 2/4] fix: address PR review comments - Fix version_files_info indentation causing Markdown code block rendering - Fix unused new_match variable in dry-run preview, now shows actual replaced line - Narrow exception catches from Exception to OSError/yaml.YAMLError Resolve m-6822381131 Co-Authored-By: Claude Opus 4.6 --- .github/workflows/centralized-release.yml | 3 +-- scripts/update-versions.py | 11 ++++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/centralized-release.yml b/.github/workflows/centralized-release.yml index e3d8cd4..f5d7839 100644 --- a/.github/workflows/centralized-release.yml +++ b/.github/workflows/centralized-release.yml @@ -203,8 +203,7 @@ jobs: paths = [e['path'] for e in config.get('version_files', [])] print(', '.join(f'\`{p}\`' for p in paths)) ") - version_files_info=" - - 更新版本号文件: ${vfiles}" + version_files_info=$'\n- 更新版本号文件: '"${vfiles}" fi pr_body="## Release v${VERSION} diff --git a/scripts/update-versions.py b/scripts/update-versions.py index 702326f..52844b7 100644 --- a/scripts/update-versions.py +++ b/scripts/update-versions.py @@ -74,7 +74,7 @@ def update_versions(config_path, version, dry_run=False): try: with open(config_file, encoding='utf-8') as f: config = yaml.safe_load(f) - except Exception as e: + except (OSError, yaml.YAMLError) as e: return { "success": False, "message": f"读取配置文件失败: {e}", @@ -138,7 +138,7 @@ def update_versions(config_path, version, dry_run=False): try: content = file_path.read_text(encoding='utf-8') - except Exception as e: + except OSError as e: return { "success": False, "message": f"读取文件失败 ({entry['path']}): {e}", @@ -161,12 +161,13 @@ def update_versions(config_path, version, dry_run=False): # 找到被替换的行用于预览 match = re.search(pattern, content, re.MULTILINE) old_line = match.group(0).strip() if match else "?" - new_match = re.search(pattern.replace('[^"]+', re.escape(version)), new_content, re.MULTILINE) - print(f" 📦 [DRY RUN] {entry['path']}: {old_line} → version={version}") + new_match = re.search(pattern, new_content, re.MULTILINE) + new_line = new_match.group(0).strip() if new_match else "?" + print(f" 📦 [DRY RUN] {entry['path']}: {old_line} → {new_line}") else: try: file_path.write_text(new_content, encoding='utf-8') - except Exception as e: + except OSError as e: return { "success": False, "message": f"写入文件失败 ({entry['path']}): {e}", From 9c5704849e0168abf532622c1c4b11462dbc31ad Mon Sep 17 00:00:00 2001 From: ThirteenLLB <1340481713@qq.com> Date: Wed, 25 Feb 2026 13:36:25 +0800 Subject: [PATCH 3/4] fix: remove unnecessary backtick escaping and add entry type validation - Remove redundant backslash escaping on backticks in Python f-string - Add isinstance(entry, dict) check before accessing version_files entries Resolve m-6822381131 Co-Authored-By: Claude Opus 4.6 --- .github/workflows/centralized-release.yml | 2 +- scripts/update-versions.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/centralized-release.yml b/.github/workflows/centralized-release.yml index f5d7839..3cd8a4b 100644 --- a/.github/workflows/centralized-release.yml +++ b/.github/workflows/centralized-release.yml @@ -201,7 +201,7 @@ jobs: with open('.release.yml') as f: config = yaml.safe_load(f) paths = [e['path'] for e in config.get('version_files', [])] - print(', '.join(f'\`{p}\`' for p in paths)) + print(', '.join(f'`{p}`' for p in paths)) ") version_files_info=$'\n- 更新版本号文件: '"${vfiles}" fi diff --git a/scripts/update-versions.py b/scripts/update-versions.py index 52844b7..8eba23f 100644 --- a/scripts/update-versions.py +++ b/scripts/update-versions.py @@ -98,6 +98,12 @@ def update_versions(config_path, version, dry_run=False): # 校验所有规则 for entry in version_files: + if not isinstance(entry, dict): + return { + "success": False, + "message": f"version_files 条目必须为对象: {entry}", + "updated": [], + } if 'path' not in entry or 'pattern' not in entry: return { "success": False, From faf911a30bd35465c02c394afd3bda6228ad9a28 Mon Sep 17 00:00:00 2001 From: ThirteenLLB <1340481713@qq.com> Date: Wed, 25 Feb 2026 13:51:58 +0800 Subject: [PATCH 4/4] fix: use two-phase write to prevent partial updates - Phase 1: read all files and compute replacements (no side effects) - Phase 2: write all files only after all validations pass - Distinguish dry_run vs actual update in success message Resolve m-6822381131 Co-Authored-By: Claude Opus 4.6 --- scripts/update-versions.py | 43 ++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/scripts/update-versions.py b/scripts/update-versions.py index 8eba23f..982bf32 100644 --- a/scripts/update-versions.py +++ b/scripts/update-versions.py @@ -127,8 +127,8 @@ def update_versions(config_path, version, dry_run=False): "updated": [], } - # 执行替换 - updated = [] + # 阶段 1:预读并生成所有替换结果,遇错立即返回(无副作用) + pending_writes = [] # list of (file_path, new_content, entry_path) config_dir = config_file.parent for entry in version_files: @@ -139,7 +139,7 @@ def update_versions(config_path, version, dry_run=False): return { "success": False, "message": f"文件不存在: {entry['path']}", - "updated": updated, + "updated": [], } try: @@ -148,7 +148,7 @@ def update_versions(config_path, version, dry_run=False): return { "success": False, "message": f"读取文件失败 ({entry['path']}): {e}", - "updated": updated, + "updated": [], } replacement = rf'\g<1>{version}\g<2>' @@ -160,32 +160,39 @@ def update_versions(config_path, version, dry_run=False): return { "success": False, "message": f"未匹配到版本号 ({entry['path']}): pattern={pattern}", - "updated": updated, + "updated": [], } if dry_run: - # 找到被替换的行用于预览 match = re.search(pattern, content, re.MULTILINE) old_line = match.group(0).strip() if match else "?" new_match = re.search(pattern, new_content, re.MULTILINE) new_line = new_match.group(0).strip() if new_match else "?" print(f" 📦 [DRY RUN] {entry['path']}: {old_line} → {new_line}") else: - try: - file_path.write_text(new_content, encoding='utf-8') - except OSError as e: - return { - "success": False, - "message": f"写入文件失败 ({entry['path']}): {e}", - "updated": updated, - } - print(f" 📦 已更新: {entry['path']} → {version}") - - updated.append(entry['path']) + pending_writes.append((file_path, new_content, entry['path'])) + + # 阶段 2:统一写入(仅 non-dry-run 且所有预检通过后执行) + updated = [] + for file_path, new_content, entry_path in pending_writes: + try: + file_path.write_text(new_content, encoding='utf-8') + except OSError as e: + return { + "success": False, + "message": f"写入文件失败 ({entry_path}): {e}", + "updated": updated, + } + print(f" 📦 已更新: {entry_path} → {version}") + updated.append(entry_path) + + if dry_run: + updated = [e['path'] for e in version_files] + action = "预览" if dry_run else "更新" return { "success": True, - "message": f"成功更新 {len(updated)} 个版本号文件", + "message": f"成功{action} {len(updated)} 个版本号文件", "updated": updated, }