ソースを参照

chore: unify plugin release workflow and version metadata

lingfengQAQ 3 ヶ月 前
コミット
dab4d85ed6

+ 64 - 0
.github/workflows/plugin-release.yml

@@ -0,0 +1,64 @@
+name: Plugin Release
+
+on:
+  workflow_dispatch:
+    inputs:
+      version:
+        description: 'Plugin version to release, for example 5.5.2'
+        required: true
+        type: string
+      release_notes:
+        description: 'Release notes used in README and GitHub Release'
+        required: true
+        type: string
+
+permissions:
+  contents: write
+
+jobs:
+  release:
+    runs-on: ubuntu-latest
+    env:
+      RELEASE_VERSION: ${{ inputs.version }}
+      RELEASE_NOTES: ${{ inputs.release_notes }}
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Setup Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: '3.11'
+
+      - name: Update release metadata
+        run: |
+          python webnovel-writer/scripts/sync_plugin_version.py \
+            --version "$RELEASE_VERSION" \
+            --release-notes "$RELEASE_NOTES"
+
+      - name: Commit release metadata
+        run: |
+          git config user.name github-actions
+          git config user.email github-actions[bot]@users.noreply.github.com
+          git add README.md webnovel-writer/.claude-plugin/plugin.json .claude-plugin/marketplace.json
+          git diff --cached --quiet && exit 0
+          git commit -m "chore: release v$RELEASE_VERSION"
+          git push origin HEAD
+
+      - name: Create and push tag
+        run: |
+          if git rev-parse "v$RELEASE_VERSION" >/dev/null 2>&1; then
+            echo "Tag v$RELEASE_VERSION already exists"
+            exit 1
+          fi
+          git tag "v$RELEASE_VERSION"
+          git push origin "v$RELEASE_VERSION"
+
+      - name: Create GitHub Release
+        uses: softprops/action-gh-release@v2
+        with:
+          tag_name: v${{ inputs.version }}
+          name: v${{ inputs.version }}
+          body: ${{ inputs.release_notes }}

+ 32 - 0
.github/workflows/plugin-version.yml

@@ -0,0 +1,32 @@
+name: Plugin Version Check
+
+on:
+  push:
+    paths:
+      - '.claude-plugin/marketplace.json'
+      - 'webnovel-writer/.claude-plugin/plugin.json'
+      - 'webnovel-writer/scripts/sync_plugin_version.py'
+      - 'README.md'
+      - '.github/workflows/plugin-version.yml'
+  pull_request:
+    paths:
+      - '.claude-plugin/marketplace.json'
+      - 'webnovel-writer/.claude-plugin/plugin.json'
+      - 'webnovel-writer/scripts/sync_plugin_version.py'
+      - 'README.md'
+      - '.github/workflows/plugin-version.yml'
+
+jobs:
+  check:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: '3.11'
+
+      - name: Check release metadata consistency
+        run: python webnovel-writer/scripts/sync_plugin_version.py --check

+ 14 - 0
README.md

@@ -117,6 +117,20 @@ model: sonnet
 | **v5.4.3** | 增强智能 RAG 上下文辅助(`auto/graph_hybrid` 回退 BM25) |
 | **v5.3** | 引入追读力系统(Hook / Cool-point / 微兑现 / 债务追踪) |
 
+## 插件发版
+
+推荐使用 GitHub Actions 的 `Plugin Release` 工作流统一发版:
+
+1. 打开仓库的 Actions 页面,选择 `Plugin Release`。
+2. 输入 `version`(例如 `5.5.2`)和 `release_notes`。
+3. 工作流会自动完成以下动作:
+   - 同步 `plugin.json`、`marketplace.json` 与 README 当前版本
+   - 提交版本变更
+   - 创建并推送 `vX.Y.Z` Tag
+   - 创建同名 GitHub Release
+
+日常开发中,`Plugin Version Check` 会在 Push / PR 时自动校验版本信息是否一致。
+
 ## 开源协议
 本项目使用 `GPL v3` 协议,详见 `LICENSE`。
 

+ 1 - 1
webnovel-writer/.claude-plugin/plugin.json

@@ -1,6 +1,6 @@
 {
   "name": "webnovel-writer",
-  "version": "5.5.0",
+  "version": "5.5.1",
   "description": "长篇网文创作系统(skills + agents + data chain + RAG)",
   "author": {
     "name": "lingfengQAQ"

+ 208 - 0
webnovel-writer/scripts/sync_plugin_version.py

@@ -0,0 +1,208 @@
+from __future__ import annotations
+
+import argparse
+import json
+import re
+from pathlib import Path
+from typing import Any
+
+
+ROOT = Path(__file__).resolve().parent.parent.parent
+PLUGIN_JSON_PATH = ROOT / "webnovel-writer" / ".claude-plugin" / "plugin.json"
+MARKETPLACE_JSON_PATH = ROOT / ".claude-plugin" / "marketplace.json"
+README_PATH = ROOT / "README.md"
+PLUGIN_NAME = "webnovel-writer"
+VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$")
+README_ROW_PATTERN = re.compile(
+    r"^\| \*\*v(?P<version>[^\s*]+)(?P<current> \(当前\))?\*\* \| (?P<notes>.*) \|$"
+)
+README_HEADER = "| 版本 | 说明 |"
+README_SEPARATOR = "|------|------|"
+
+
+def load_json(path: Path) -> dict[str, Any]:
+    with path.open("r", encoding="utf-8") as file:
+        return json.load(file)
+
+
+def save_json(path: Path, payload: dict[str, Any]) -> None:
+    with path.open("w", encoding="utf-8", newline="\n") as file:
+        json.dump(payload, file, ensure_ascii=False, indent=2)
+        file.write("\n")
+
+
+def load_text(path: Path) -> str:
+    return path.read_text(encoding="utf-8")
+
+
+def save_text(path: Path, content: str) -> None:
+    path.write_text(content, encoding="utf-8", newline="\n")
+
+
+def get_marketplace_plugin(payload: dict[str, Any]) -> dict[str, Any]:
+    plugins = payload.get("plugins", [])
+    for plugin in plugins:
+        if plugin.get("name") == PLUGIN_NAME:
+            return plugin
+    raise ValueError(f"Plugin {PLUGIN_NAME} not found in marketplace.json")
+
+
+def parse_readme_rows(lines: list[str]) -> list[dict[str, Any]]:
+    rows: list[dict[str, Any]] = []
+    for index, line in enumerate(lines):
+        match = README_ROW_PATTERN.match(line.strip())
+        if not match:
+            continue
+        rows.append(
+            {
+                "index": index,
+                "version": match.group("version"),
+                "notes": match.group("notes"),
+                "is_current": bool(match.group("current")),
+            }
+        )
+    return rows
+
+
+def format_readme_row(version: str, notes: str, is_current: bool) -> str:
+    marker = " (当前)" if is_current else ""
+    return f"| **v{version}{marker}** | {notes.strip()} |"
+
+
+def get_readme_current_version(content: str) -> str:
+    rows = parse_readme_rows(content.splitlines())
+    current_rows = [row for row in rows if row["is_current"]]
+    if len(current_rows) != 1:
+        raise ValueError("README.md must contain exactly one current release row")
+    return str(current_rows[0]["version"])
+
+
+def update_readme_release(content: str, version: str, release_notes: str | None) -> str:
+    lines = content.splitlines()
+
+    try:
+        header_index = next(index for index, line in enumerate(lines) if line.strip() == README_HEADER)
+    except StopIteration as error:
+        raise ValueError("README.md release table header not found") from error
+
+    separator_index = header_index + 1
+    if separator_index >= len(lines) or lines[separator_index].strip() != README_SEPARATOR:
+        raise ValueError("README.md release table separator not found")
+
+    rows = parse_readme_rows(lines)
+    target_row = next((row for row in rows if row["version"] == version), None)
+
+    for row in rows:
+        is_target = row["version"] == version
+        notes = release_notes if is_target and release_notes is not None else row["notes"]
+        lines[row["index"]] = format_readme_row(row["version"], notes, is_target)
+
+    if target_row is None:
+        if not release_notes:
+            raise ValueError(
+                "Release notes are required when the target version does not exist in README.md"
+            )
+        lines.insert(separator_index + 1, format_readme_row(version, release_notes, True))
+
+    return "\n".join(lines) + "\n"
+
+
+def sync_versions(version: str | None = None, release_notes: str | None = None) -> tuple[str, str, bool]:
+    plugin_payload = load_json(PLUGIN_JSON_PATH)
+    marketplace_payload = load_json(MARKETPLACE_JSON_PATH)
+    readme_content = load_text(README_PATH)
+    marketplace_plugin = get_marketplace_plugin(marketplace_payload)
+
+    previous_version = str(plugin_payload.get("version", ""))
+    target_version = version or previous_version
+    changed = False
+
+    if plugin_payload.get("version") != target_version:
+        plugin_payload["version"] = target_version
+        changed = True
+
+    if marketplace_plugin.get("version") != target_version:
+        marketplace_plugin["version"] = target_version
+        changed = True
+
+    updated_readme = update_readme_release(readme_content, target_version, release_notes)
+    if updated_readme != readme_content:
+        save_text(README_PATH, updated_readme)
+        changed = True
+
+    if changed:
+        save_json(PLUGIN_JSON_PATH, plugin_payload)
+        save_json(MARKETPLACE_JSON_PATH, marketplace_payload)
+
+    return previous_version, target_version, changed
+
+
+def check_versions() -> int:
+    plugin_payload = load_json(PLUGIN_JSON_PATH)
+    marketplace_payload = load_json(MARKETPLACE_JSON_PATH)
+    readme_content = load_text(README_PATH)
+    marketplace_plugin = get_marketplace_plugin(marketplace_payload)
+
+    plugin_version = str(plugin_payload.get("version", ""))
+    marketplace_version = str(marketplace_plugin.get("version", ""))
+    readme_version = get_readme_current_version(readme_content)
+
+    mismatches: list[str] = []
+    if plugin_version != marketplace_version:
+        mismatches.append(
+            f"plugin.json={plugin_version}, marketplace.json={marketplace_version}"
+        )
+    if plugin_version != readme_version:
+        mismatches.append(f"plugin.json={plugin_version}, README.md={readme_version}")
+
+    if mismatches:
+        print("Version mismatch detected:")
+        for mismatch in mismatches:
+            print(f"- {mismatch}")
+        return 1
+
+    print(f"Versions are in sync: {plugin_version}")
+    return 0
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Sync Claude plugin release metadata")
+    parser.add_argument(
+        "--check",
+        action="store_true",
+        help="Check whether plugin metadata and README release info are in sync",
+    )
+    parser.add_argument(
+        "--version",
+        help="Update release metadata to the given semantic version",
+    )
+    parser.add_argument(
+        "--release-notes",
+        help="Release notes used for the README current release row",
+    )
+    args = parser.parse_args()
+
+    if args.version and not VERSION_PATTERN.fullmatch(args.version):
+        parser.error("--version must look like X.Y.Z")
+
+    try:
+        if args.check:
+            return check_versions()
+
+        previous_version, target_version, changed = sync_versions(
+            version=args.version,
+            release_notes=args.release_notes,
+        )
+    except ValueError as error:
+        print(f"Error: {error}")
+        return 1
+
+    if changed:
+        print(f"Updated release metadata: {previous_version} -> {target_version}")
+    else:
+        print(f"No changes needed. Current version: {target_version}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())