# -*- coding: utf-8 -*- """树脂模板打包与版本发布 —— issue #85(父 Issue #13 子任务)。 按 `version.yaml`(#13)的 assets 清单把 iAOP-Template-Resin 打包发布: - `validate_version()`:semver 校验 + assets 清单文件/目录存在性检查 (发布前门禁,防止打包不完整); - `package_template()`:按清单打包 zip(含 version.yaml + README), 输出 `dist/resin-{version}.zip`; - `render_release_notes()`:从 version.yaml 生成 Markdown 发布说明 (版本/行业/基线/资产清单/备注),供仓库 Release 使用。 纯标准库实现(zipfile),无第三方依赖。 """ from __future__ import annotations import os import re import zipfile from typing import List, Optional import yaml #: 模板根目录(相对本模块) TEMPLATE_ROOT = os.path.dirname(os.path.abspath(__file__)) #: 版本清单(与 #13 同构) VERSION_PATH = os.path.join(TEMPLATE_ROOT, "version.yaml") #: semver 校验(x.y.z) _SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") #: 打包时始终包含的清单外文件 _ALWAYS_INCLUDE = ["version.yaml", "README.md"] def load_version(path: str = VERSION_PATH) -> dict: """加载版本清单。""" with open(path, "r", encoding="utf-8") as fh: return yaml.safe_load(fh) or {} def validate_version(path: str = VERSION_PATH) -> List[str]: """版本清单校验:semver 合法 + assets 文件/目录存在。 Returns: 问题列表(空 = 校验通过)。 """ problems: List[str] = [] version = load_version(path) ver = str(version.get("version", "")) if not _SEMVER_RE.match(ver): problems.append(f"version 非法(需 semver x.y.z):{ver!r}") for rel in version.get("assets", []): full = os.path.join(TEMPLATE_ROOT, rel) if not os.path.exists(full): problems.append(f"资产缺失:{rel}") return problems def _add_entry(zf: zipfile.ZipFile, rel: str) -> None: """把单个文件或目录加入 zip(保留相对路径)。""" full = os.path.join(TEMPLATE_ROOT, rel) if os.path.isdir(full): for root, _, files in os.walk(full): for name in files: abs_path = os.path.join(root, name) zf.write(abs_path, os.path.relpath(abs_path, TEMPLATE_ROOT)) else: zf.write(full, rel) def package_template(path: str = VERSION_PATH, output_dir: Optional[str] = None) -> str: """按 assets 清单打包模板为 zip。 Returns: 生成的 zip 文件路径。 """ version = load_version(path) ver = str(version.get("version", "")) problems = validate_version(path) if problems: raise ValueError("打包前校验未通过:" + "; ".join(problems)) dist = output_dir or os.path.join(TEMPLATE_ROOT, "dist") os.makedirs(dist, exist_ok=True) zip_path = os.path.join(dist, f"resin-{ver}.zip") with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: entries = _ALWAYS_INCLUDE + list(version.get("assets", [])) for rel in dict.fromkeys(entries): # 去重保序 _add_entry(zf, rel) return zip_path def render_release_notes(path: str = VERSION_PATH) -> str: """从 version.yaml 生成 Markdown 发布说明。""" version = load_version(path) lines = [ f"# iAOP-Template-Resin v{version.get('version', '?')}", "", f"- 行业:{version.get('industry', '')}", f"- 基线:{version.get('baseline', '')}", f"- 并行:{version.get('parallel_with', '')}", f"- 状态:{version.get('status', '')}", "", "## 资产清单", ] lines += [f"- `{a}`" for a in version.get("assets", [])] notes = version.get("notes", []) if notes: lines.append("") lines.append("## 备注") lines += [f"- {n}" for n in notes] return "\n".join(lines) + "\n"