diff --git a/.github/workflows/marketplace-release.yml b/.github/workflows/marketplace-release.yml new file mode 100644 index 0000000..0190462 --- /dev/null +++ b/.github/workflows/marketplace-release.yml @@ -0,0 +1,83 @@ +name: Marketplace release + +on: + push: + branches: [main] + paths: + - 'releases/marketplace.json' + pull_request: + paths: + - '.agents/plugins/marketplace.json' + - 'plugins/**' + - 'releases/**' + - 'scripts/release_marketplace.py' + - 'tests/release/**' + - '.github/workflows/marketplace-release.yml' + - 'RELEASING.md' + workflow_dispatch: + +permissions: + contents: read + +env: + PYTHONUTF8: '1' + PYTHONIOENCODING: utf-8 + +concurrency: + group: why-ping-marketplace-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify: + name: Verify / ${{ matrix.os }} / Node.js ${{ matrix.node }} + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + node: ['22', '24'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: ${{ matrix.node }} + package-manager-cache: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.12' + - name: Validate every marketplace entry + run: python scripts/release_marketplace.py check + - name: Release pipeline regression tests (synthetic, no network) + run: python -m unittest discover -s tests/release -p "test_*.py" -v + - name: Test all registered plugins + run: python scripts/release_marketplace.py test-plugins + - name: Verify committed-source archives and manifest + run: python scripts/release_marketplace.py build --commit "${{ github.sha }}" + + publish: + name: Publish marketplace and independent plugin releases + needs: verify + if: github.repository == 'oiOxOio/codex-plugins' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.12' + - name: Publish tested immutable snapshot and verify Latest + env: + GITHUB_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ github.sha }} + run: python scripts/release_marketplace.py publish --commit "$RELEASE_SHA" diff --git a/.gitignore b/.gitignore index bf0d015..d42ec16 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ venv/ Thumbs.db auth.json hosts.yml + +# 市场发布的本地/CI 构建产物 +/dist/releases/ diff --git a/README.md b/README.md index a8e2ac1..0be469c 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,19 @@ 目录只列出已经提供的插件。各插件的系统支持、作用范围、额度消耗及安全边界,请阅读对应说明后再安装。 +## 市场发布与版本下载 + +[查看最新市场快照](https://github.com/oiOxOio/codex-plugins/releases/latest) · [全部发布与历史版本](https://github.com/oiOxOio/codex-plugins/releases) · [多插件发布规范](RELEASING.md) + +仓库的 **Latest 是完整市场总览**,同时列出全部已注册插件及其独立版本,不再用某一个插件的版本代表整个仓库。市场快照采用 `marketplace-YYYY.MM.DD` 标签;单插件继续使用 `<插件名>-v<版本>`,历史标签不移动。 + +市场 Release 提供完整市场包、各插件独立包、版本清单与 SHA-256 校验。默认安装命令跟随所配置市场来源;需要复现某个 Release 时,使用该页面给出的完整 commit SHA,并在独立 `CODEX_HOME` 中验收。发布说明会区分自动化测试和未完成的实机验证。 + +新增插件或准备下一次发布时,更新 [发布计划](releases/marketplace.json)。发布工作流从市场目录自动收录所有插件,先通过六项平台/Node 组合验证,再发布缺少的独立版本并更新市场 Latest;普通代码提交不会自动发布。详见 [RELEASING.md](RELEASING.md)。 + ## 快速开始 -需要支持 `codex plugin` 命令的 Codex CLI;使用桌面任务工具的插件还需要 Codex 桌面端。依赖按插件分别安装:当前标题插件的自动钩子和批量清单使用 Node.js 22+,不需要 Python 或 `npm install`;手动单任务只依赖桌面任务工具。 +需要支持 `codex plugin` 命令的 Codex CLI;使用桌面任务工具的插件还需要 Codex 桌面端。依赖按插件分别安装:当前标题插件的自动钩子和批量清单使用 Node.js 22+,不需要 Python 或 `npm install`;手动单任务只依赖桌面任务工具。安全视觉迭代是纯技能插件,Blender、项目运行时和截图工具按具体任务选用,不会随安装自动下载。 先添加远程市场: @@ -29,10 +39,11 @@ codex plugin marketplace add https://github.com/oiOxOio/codex-plugins.git ``` -再安装需要的插件,例如: +再按需选择插件安装,不必全部安装: ```powershell codex plugin add auto-thread-title@why-ping +codex plugin add dream-loop-safe@why-ping ``` 添加市场与安装插件是两件事。本市场按需提供插件,不会因为添加市场就批量安装所有插件;以后也可从 `why-ping` 选择其他插件。 @@ -101,6 +112,9 @@ codex-plugins/ │ ├── config.json # 安全默认配置(用户配置独立保存) │ ├── README.md # 使用说明 │ └── CHANGELOG.md # 正式版本更新记录 +├── releases/marketplace.json # 明确的市场发布计划 +├── scripts/release_marketplace.py # 维护端发布与归档工具 +├── RELEASING.md # 聚合市场与单插件两层发布规范 ├── CONTRIBUTING.md # 新增插件、验证与发布 └── README.md # 市场总览与安装入口 ``` diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..fb267e2 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,60 @@ +# 多插件市场发布规范 + +本仓库是 **Why Ping / why-ping 多插件聚合市场**,不是自动对话标题的单插件仓库。插件各自保持语义化版本,市场快照只是一次完整目录与版本组合;不为了聚合发布强行统一或提升插件版本。 + +## 两层 Release + +| 层级 | 标签格式 | 用途 | Latest | +| --- | --- | --- | --- | +| 市场快照 | `marketplace-YYYY.MM.DD`,同日追加 `.2`、`.3` | 全量插件目录、独立版本、安装入口、完整市场包及校验 | 是 | +| 单插件 | `-v` | 该插件的更新记录、独立源码包与验证边界 | 否 | + +例如本次市场快照包含 `auto-thread-title 0.2.1` 和 `dream-loop-safe 0.1.0`。原有 `auto-thread-title-v0.2.0` / `auto-thread-title-v0.2.1` 标签与历史正文保留;旧 Release 补充“单插件发布”提示,标题统一包含插件标识。不会把缺少新插件的旧标签改名伪装成全市场快照。 + +新增插件发布时不能只创建一个独立 Release 就结束:市场快照须同步列出所有已注册插件。市场列表和版本从 `.agents/plugins/marketplace.json` 及各插件 `plugin.json` 读取,不手写一份容易过期的版本表。 + +## 实际发布流程 + +1. 完成相关插件的版本与 CHANGELOG、清单和安装/实机验收;保留适用的审批与钩子信任检查。 +2. 修改 [releases/marketplace.json](releases/marketplace.json):选择从未使用过的市场标签,填写中文摘要和**每一个**插件的测试命令、测试覆盖范围与尚未完成的实机项目。`validation` 的键必须与市场目录完全一致,不能漏掉新插件。 +3. 提交 PR。[Marketplace release](.github/workflows/marketplace-release.yml) 在 Ubuntu、Windows、macOS × Node.js 22/24 上验证发布脚本、全部插件及提交源码归档。PR 只能测试,不发布。 +4. 合并发布计划到 `main` 后,同一工作流重新验证精确的合并提交。六项全部成功后,发布作业才获得 `contents: write`;创建草稿、上传完整附件,再发布缺失的单插件版本,最后将市场快照设为 Latest。 +5. 核对实际 Release、完整 commit、插件清单、附件摘要和 `releases/latest`。未执行的 Codex 安装、桌面操作、模型行为或 Blender 渲染必须明确标注,不能把 CI 当成实机验收。 + +**只有发布计划文件合入 main,或维护者在 main 手动触发工作流,才会尝试发布。** 普通代码/文档提交不会自动生成大量 Release。变更插件而未提升版本、重用已指向其他提交的市场标签、已上传附件摘要冲突,都会中止,不强推标签、不删除附件或覆盖历史。 + +工作流失败后,优先重跑同一个提交的失败作业。新 main 提交复用旧快照标签会被拒绝,应创建新快照;不要通过删除旧标签“解决”。新独立插件包仍在草稿时可以恢复上传;已经发布的历史版本只规范展示提示,不重新打包或移动标签。发布步骤不是跨 GitHub API 的原子事务;中途失败应查看草稿与日志,并对同提交重试,不能声称已全部发布。 + +本次是**分发目录与发布机制整改**,不改变两个插件的代码、版本或权限,也不补造之前未完成的安装/运行验收。面向正式运行能力的验收要求仍按 [CONTRIBUTING.md](CONTRIBUTING.md) 及各插件说明执行。 + +## 附件与固定版本 + +市场 Release 的附件:完整 `why-ping-marketplace-*.zip`、所有 `<插件名>-<版本>.zip`、`release-manifest.json` 和 `SHA256SUMS`。独立 ZIP 只包含对应插件目录;完整市场 ZIP 保留市场清单与插件路径。全部只取**精确 commit 中的普通文件**,拒绝链接、子模块、凭据文件与缓存;不打包未提交文件。 + +ZIP 使用固定条目时间、顺序和文件权限,不受工作目录修改时间影响。版本清单记录完整 commit、每个插件版本与 SHA-256;校验值用于完整性核对,不是签名、安全认证或安装成功证明。 + +GitHub 自动附带的 Source code ZIP/TAR 无论在哪个单插件 Release 中,都是整个仓库源码,不能宣传成“只有该插件”。 + +默认命令安装所配置市场来源的当前版本: + +```powershell +codex plugin marketplace upgrade why-ping +codex plugin add dream-loop-safe@why-ping +``` + +精确复现时,先使用独立 `CODEX_HOME`,再运行 Release 页面给出的 `marketplace add ... --ref <完整SHA>`;不要覆盖日常 `why-ping` 来源。市场 tag 不会被工作流移动,但完整 SHA 是最终精确标识。添加市场不等于已安装全部插件,仍须分别选择并在新任务验证。 + +## 本地检查 + +发布维护工具需要 Python 3.12 与 Git,仅使用 Python 标准库;各插件测试运行时由 `validation.command` 指定。本仓库现有两个插件测试使用 Node.js 22+。这些是**维护端/CI 依赖**,不会增加插件使用端的依赖。 + +```text +python scripts/release_marketplace.py check +python -m unittest discover -s tests/release -p "test_*.py" -v +python scripts/release_marketplace.py test-plugins +python scripts/release_marketplace.py build --commit <当前完整HEAD-SHA> +``` + +本地 `build` 只生成文件,不发布;`publish` 限定为本仓库 main 的 Actions,且提交必须等于该工作流 SHA。验证作业只读,发布 token 仅在发布步骤传入;不用个人 PAT、不安装 npm/pip 依赖、不保留 checkout 凭据。第三方 Actions 固定到本次核实的完整 commit。 + +发布脚本的网络端点限定为本仓库 GitHub API 与附件上传端点;不跟随携带凭据的重定向,不打印 token。未来替换 Actions 或扩展发布命令时仍需代码审查,不能因工具通过测试就扩大权限。 diff --git a/releases/marketplace.json b/releases/marketplace.json new file mode 100644 index 0000000..641dbef --- /dev/null +++ b/releases/marketplace.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "tag": "marketplace-2026.09.09", + "title": "Why Ping 插件市场 · 2026.09.09", + "summary": [ + "将仓库发布入口调整为多插件市场总览,完整列出所有已注册插件的独立版本与安装方式。", + "纳入自动对话标题 0.2.1 与安全视觉迭代 0.1.0;保留历史单插件标签,不改插件代码或版本。", + "增加完整市场包、独立插件包、版本清单和 SHA-256 校验;Latest 由市场快照承担。" + ], + "validation": { + "auto-thread-title": { + "command": ["node", "--test"], + "scope": "合成数据单元、策略和进程测试;不执行真实任务改名。", + "runtime": "未执行真实 Codex 桌面安装、钩子信任和改名验收。" + }, + "dream-loop-safe": { + "command": ["node", "--test"], + "scope": "静态包结构、元数据、链接与策略文本防回退测试。", + "runtime": "未执行真实 Codex 安装、模型行为对抗测试或 Blender 渲染。" + } + } +} diff --git a/scripts/release_marketplace.py b/scripts/release_marketplace.py new file mode 100644 index 0000000..5111494 --- /dev/null +++ b/scripts/release_marketplace.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""发布市场快照与缺失的单插件版本;仅使用标准库,不移动旧标签。""" +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +import zipfile + +REPOSITORY = "oiOxOio/codex-plugins" +BASE = f"https://github.com/{REPOSITORY}" +NAME = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*\Z") +VERSION = re.compile(r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\Z") +SHA = re.compile(r"[0-9a-f]{40}\Z") +START = "" +END = "" + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def git(root, *args): + return subprocess.check_output(["git", *args], cwd=root, timeout=60) + + +def read_json(path): + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def load_catalog(root): + root = Path(root).resolve() + plan = read_json(root / "releases/marketplace.json") + market = read_json(root / ".agents/plugins/marketplace.json") + require(plan.get("schemaVersion") == 1, "不支持的发布计划版本") + require(re.fullmatch(r"marketplace-\d{4}\.\d{2}\.\d{2}(?:\.\d+)?", plan.get("tag", "")), "非法市场标签") + require(isinstance(plan.get("title"), str) and plan["title"].strip(), "缺少市场标题") + require(isinstance(plan.get("summary"), list) and all(isinstance(s, str) for s in plan["summary"]), "发布摘要必须为文本数组") + require(market.get("name") == "why-ping" and market.get("plugins"), "必须是非空 why-ping 市场") + catalog = [] + seen = set() + for entry in market["plugins"]: + name = entry.get("name", "") + require(NAME.fullmatch(name) and name not in seen, "插件名称无效或重复") + seen.add(name) + relative = f"plugins/{name}" + require(entry.get("source") == {"source": "local", "path": f"./{relative}"}, "插件来源必须指向同名仓库内目录") + directory = root / relative + require(directory.resolve() == directory and not directory.is_symlink(), "拒绝插件路径链接或越界") + manifest = read_json(directory / ".codex-plugin/plugin.json") + require(manifest.get("name") == name, "市场与插件清单名称不一致") + version = manifest.get("version", "") + require(VERSION.fullmatch(version), "正式插件版本必须是 MAJOR.MINOR.PATCH") + require(manifest.get("author", {}).get("name") == "Why.Ping", "发布者不一致") + for document in ("README.md", "CHANGELOG.md"): + p = directory / document + require(p.is_file() and p.resolve().is_relative_to(directory), f"缺少有效文档:{name}/{document}") + changes = (directory / "CHANGELOG.md").read_text(encoding="utf-8") + require(re.search(r"^##\s+\[?" + re.escape(version) + r"(?:\]|\s|$)", changes, re.M), f"更新记录缺少 {version}") + validation = plan.get("validation", {}).get(name, {}) + command = validation.get("command") + require(isinstance(command, list) and command and all(isinstance(v, str) and v and "\n" not in v and "\x00" not in v for v in command), f"缺少测试命令:{name}") + require(all(isinstance(validation.get(k), str) and validation[k] for k in ("scope", "runtime")), f"缺少验证边界:{name}") + catalog.append({"name": name, "version": version, "displayName": manifest.get("interface", {}).get("displayName", name), + "description": manifest.get("interface", {}).get("shortDescription", manifest.get("description", "")), + "path": relative, "tag": f"{name}-v{version}", "validation": validation}) + require(set(plan.get("validation", {})) == seen, "测试计划必须覆盖且仅覆盖市场的全部插件") + return plan, catalog + + +def version_changes(root, plugin): + text = (Path(root) / plugin["path"] / "CHANGELOG.md").read_text(encoding="utf-8") + header = re.search(r"^##\s+\[?" + re.escape(plugin["version"]) + r"(?:\]|\s|$).*\n", text, re.M) + require(header, "缺少版本更新记录") + return re.split(r"^##\s+", text[header.end():], maxsplit=1, flags=re.M)[0].strip() + + +def scope_notice(body, tag, commit): + # 只替换本工具维护的提示区块;历史更新正文原样保留。 + if body.startswith(START): + require(END in body, "历史提示区块损坏,拒绝覆盖") + body = body.split(END, 1)[1].lstrip("\n") + return (f"{START}\n> **单插件发布,不代表整个市场。** 全部插件请查看 [Why Ping 市场最新快照]({BASE}/releases/latest)。\n" + f"> 本版本标签:`{tag}`;原始提交:`{commit}`。下方不带 `--ref` 的安装命令跟随所配置市场来源的当前版本,并非固定此版本。\n" + f"> 精确复现请在独立 `CODEX_HOME` 中添加市场并指定 `--ref {commit}`,不要覆盖日常市场配置。\n{END}\n\n{body}") + + +def marketplace_notes(plan, catalog, commit, run_url=None): + rows = [] + for p in catalog: + rows.append(f"| `{p['name']}` · {p['displayName']} | `{p['version']}` | [说明]({BASE}/blob/{commit}/{p['path']}/README.md) · [单插件发布]({BASE}/releases/tag/{p['tag']}) |") + install = "\n".join(f"codex plugin add {p['name']}@why-ping" for p in catalog) + verification = "\n".join(f"- **{p['name']}**:{p['validation']['scope']} {p['validation']['runtime']}" for p in catalog) + run = f"\n本次门禁运行:[GitHub Actions]({run_url})。\n" if run_url else "\n发布须通过对应提交的六平台/Node 组合门禁;本地生成不表示 CI 已通过。\n" + return (f"# {plan['title']}\n\n这是 **why-ping 多插件市场快照**,不是任何单一插件的统一版本号。插件独立版本、独立安装;市场日期只标识这一组发布内容。\n\n" + + "\n".join(f"- {s}" for s in plan["summary"]) + + f"\n\n## 本快照包含的全部插件\n\n| 插件 | 版本 | 入口 |\n| --- | --- | --- |\n" + "\n".join(rows) + + f"\n\n## 安装当前市场版本\n\n首次添加市场:\n\n```powershell\ncodex plugin marketplace add https://github.com/{REPOSITORY}.git\n```\n\n已添加市场时先刷新,再按需选择插件;不必全部安装:\n\n```powershell\ncodex plugin marketplace upgrade why-ping\n{install}\ncodex plugin list --json\n```\n\n" + + f"以上命令跟随已配置的市场来源,不保证一直安装本快照版本;新任务中检查技能发现和必要的钩子授权。\n\n## 固定本快照\n\n市场标签:`{plan['tag']}`;完整提交:`{commit}`。\n\n在**独立 CODEX_HOME** 中使用以下来源,避免改写日常 why-ping 配置:\n\n```powershell\ncodex plugin marketplace add https://github.com/{REPOSITORY}.git --ref {commit}\n```\n\n再按上表安装所需插件。完整 SHA 才是精确来源;不会移动或重用旧发布标签。\n\n" + + "## 下载与校验\n\n附件提供完整市场 ZIP、每个插件的独立 ZIP、`release-manifest.json` 及 `SHA256SUMS`。完整市场包解压后保留 `.agents/plugins/marketplace.json` 与 `plugins/` 相对布局;单插件 ZIP 不是市场包,不能直接当作 marketplace 来源。GitHub 自动 Source code ZIP/TAR 始终是整个仓库,不是某一个插件的专属包。校验值用于一致性核对,不是签名或安全认证。\n\n" + + "## 已验证范围与未验证事项\n\n" + verification + run + + "\nCI 是结构/单元/合成数据验证,不等于真实 Codex 安装、桌面操作或视觉渲染验收。此发布不改变插件代码、权限或用户配置,也不宣称未经完成的实机验收已通过。\n") + + +def committed_files(root, commit): + require(SHA.fullmatch(commit), "必须提供完整 commit SHA") + records = git(root, "ls-tree", "-r", "-z", commit).split(b"\0") + files = {} + total = 0 + for record in records: + if not record: + continue + metadata, encoded = record.split(b"\t", 1) + mode, kind, oid = metadata.decode("ascii").split() + name = encoded.decode("utf-8") + path = PurePosixPath(name) + require(mode in ("100644", "100755") and kind == "blob", f"拒绝链接、子模块或非常规文件:{name}") + require(not path.is_absolute() and ".." not in path.parts and "\\" not in name and "\n" not in name, "非法归档路径") + require(not any(part in (".git", "node_modules", ".dream-loop") for part in path.parts), "拒绝缓存或工作记录") + require(not any(part == ".env" or (part.startswith(".env.") and part != ".env.example") for part in path.parts), "拒绝环境凭据文件") + blob = git(root, "cat-file", "blob", oid) + total += len(blob) + require(total <= 50 * 1024 * 1024, "仓库超过 50 MiB 归档预算,请人工检查") + files[name] = (mode, blob) + return files + + +def make_zip(files, prefix, subset=""): + # 固定 ZIP 时间与顺序;版本相同且文件字节相同,插件包校验值不随发布日变化。 + result = io.BytesIO() + with zipfile.ZipFile(result, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for name, (mode, data) in sorted(files.items()): + if subset and not name.startswith(subset + "/"): + continue + relative = name[len(subset) + 1:] if subset else name + info = zipfile.ZipInfo(prefix + "/" + relative, date_time=(2026, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = (int(mode, 8) << 16) + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data) + return result.getvalue() + + +def build(root, commit, destination): + root, destination = Path(root).resolve(), Path(destination).resolve() + require(SHA.fullmatch(commit) and git(root, "rev-parse", "HEAD").decode().strip() == commit, "工作树不在待发布提交") + require(not git(root, "diff", "--name-only", "HEAD"), "存在已跟踪文件改动,拒绝发布") + plan, catalog = load_catalog(root) + files = committed_files(root, commit) + # 计划、清单和文档必须来自提交,不能让未跟踪文件冒充发布输入。 + for name in ["releases/marketplace.json", ".agents/plugins/marketplace.json"] + [f"{p['path']}/{f}" for p in catalog for f in (".codex-plugin/plugin.json", "README.md", "CHANGELOG.md")]: + require(name in files and (root / name).read_bytes() == files[name][1], "发布输入不是提交中的文件") + payloads = {f"why-ping-{plan['tag']}.zip": make_zip(files, "why-ping")} + manifest = {"schemaVersion": 1, "repository": REPOSITORY, "marketplace": "why-ping", "tag": plan["tag"], "commit": commit, "plugins": []} + for p in catalog: + filename = f"{p['name']}-{p['version']}.zip" + payloads[filename] = make_zip(files, p["name"], p["path"]) + manifest["plugins"].append({k: p[k] for k in ("name", "displayName", "version", "path", "tag", "validation")} | {"asset": filename, "sha256": hashlib.sha256(payloads[filename]).hexdigest()}) + manifest["marketplaceAsset"] = next(iter(payloads)) + manifest["marketplaceSha256"] = hashlib.sha256(payloads[manifest["marketplaceAsset"]]).hexdigest() + payloads["release-manifest.json"] = (json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode() + payloads["SHA256SUMS"] = "".join(f"{hashlib.sha256(v).hexdigest()} {k}\n" for k, v in sorted(payloads.items())).encode() + destination.mkdir(parents=True, exist_ok=True) + for name, data in payloads.items(): + target = destination / name + require(not target.is_symlink(), "拒绝输出符号链接") + target.write_bytes(data) + return plan, catalog, payloads + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None # 不把凭据随重定向带到其他主机。 + + +class GitHubAPI: + def __init__(self, token): + self.token = token + self.opener = urllib.request.build_opener(NoRedirect) + + def request(self, method, route, data=None, missing=False, upload=False): + host = "uploads.github.com" if upload else "api.github.com" + url = f"https://{host}/repos/{REPOSITORY}/{route}" + raw = data if isinstance(data, bytes) else (json.dumps(data).encode() if data is not None else None) + request = urllib.request.Request(url, data=raw, method=method, headers={"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "why-ping-marketplace-release", "Content-Type": "application/octet-stream" if upload else "application/json"}) + try: + with self.opener.open(request, timeout=60) as response: + return json.load(response) + except urllib.error.HTTPError as error: + if missing and error.code == 404: + return None + raise RuntimeError(f"GitHub {method} {route.split('?')[0]} 返回 HTTP {error.code};未记录凭据或响应正文") from None + + +def tag_commit(api, tag): + ref = api.request("GET", "git/ref/tags/" + urllib.parse.quote(tag, safe=""), missing=True) + if ref is None: + return None + obj = ref["object"] + for _ in range(8): + if obj["type"] == "commit": + return obj["sha"] + require(obj["type"] == "tag", "标签不是 commit/tag") + obj = api.request("GET", "git/tags/" + obj["sha"])["object"] + raise ValueError("嵌套标签过深") + + +def ensure_tag(api, tag, commit): + existing = tag_commit(api, tag) + require(existing in (None, commit), f"拒绝移动已存在标签:{tag}") + if existing is None: + api.request("POST", "git/refs", {"ref": f"refs/tags/{tag}", "sha": commit}) + + +def ensure_asset(api, release, filename, data): + existing = next((a for a in release.get("assets", []) if a["name"] == filename), None) + if existing: + require(existing.get("digest") == "sha256:" + hashlib.sha256(data).hexdigest(), f"现有附件摘要不匹配或不可验证,拒绝覆盖:{filename}") + return + api.request("POST", f"releases/{release['id']}/assets?name=" + urllib.parse.quote(filename, safe=""), data, upload=True) + + +def publish(root, commit, destination, api): + plan, catalog, payloads = build(root, commit, destination) + current = tag_commit(api, plan["tag"]) + require(current in (None, commit), "市场标签已绑定其他提交;请创建新的快照编号,不能重写历史") + releases = [] + for page in range(1, 11): + batch = api.request("GET", f"releases?per_page=100&page={page}") + releases.extend(batch) + if len(batch) < 100: + break + else: + raise ValueError("Release 超过分页预算,请人工检查") + by_tag = {r["tag_name"]: r for r in releases} + latest_release = api.request("GET", "releases/latest", missing=True) + if latest_release and latest_release["tag_name"].startswith("marketplace-") and latest_release["tag_name"] != plan["tag"]: + latest_commit = tag_commit(api, latest_release["tag_name"]) + require(latest_commit and subprocess.run(["git", "merge-base", "--is-ancestor", latest_commit, commit], cwd=root, timeout=30).returncode == 0, + "拒绝让旧提交覆盖较新的市场 Latest") + run_url = f"{BASE}/actions/runs/{os.environ['GITHUB_RUN_ID']}" if os.environ.get("GITHUB_RUN_ID", "").isdigit() else None + planned_new, historical = [], [] + # 先验证所有历史绑定与当前独立版本,再进行任何远端写入。 + for release in releases: + for p in catalog: + version = release["tag_name"].removeprefix(p["name"] + "-v") + if not release["tag_name"].startswith(p["name"] + "-v") or not VERSION.fullmatch(version): + continue + original_commit = tag_commit(api, release["tag_name"]) + require(original_commit and SHA.fullmatch(original_commit), "历史发布缺少有效标签") + old_manifest = json.loads(git(root, "show", f"{original_commit}:{p['path']}/.codex-plugin/plugin.json")) + require(old_manifest["name"] == p["name"] and old_manifest["version"] == version, "历史标签与插件版本不符") + historical.append((release, p, version, original_commit)) + for p in catalog: + original_commit = tag_commit(api, p["tag"]) + if original_commit: + require(git(root, "rev-parse", f"{original_commit}:{p['path']}") == git(root, "rev-parse", f"{commit}:{p['path']}"), f"{p['name']} 内容已变但版本未变,拒绝发布") + if p["tag"] not in by_tag: + planned_new.append((p, original_commit or commit)) + ensure_tag(api, plan["tag"], commit) + aggregate = by_tag.get(plan["tag"]) + if aggregate is None: + aggregate = api.request("POST", "releases", {"tag_name": plan["tag"], "target_commitish": commit, "name": plan["title"], "body": marketplace_notes(plan, catalog, commit, run_url), "draft": True, "prerelease": False, "make_latest": "false"}) + for name, data in payloads.items(): + ensure_asset(api, aggregate, name, data) + pending = [] + for p, target in planned_new: + ensure_tag(api, p["tag"], target) + body = scope_notice(f"## {p['displayName']} {p['version']}\n\n{version_changes(root, p)}\n\n[版本说明]({BASE}/blob/{target}/{p['path']}/README.md)\n\n**验证边界**:{p['validation']['scope']} {p['validation']['runtime']}\n", p["tag"], target) + release = api.request("POST", "releases", {"tag_name": p["tag"], "target_commitish": target, "name": f"{p['name']} v{p['version']} · {p['displayName']}", "body": body, "draft": True, "prerelease": False, "make_latest": "false"}) + pending.append((release, p)) + # 恢复上次中断时尚未发布的当前独立版本草稿。 + for p in catalog: + if p["tag"] in by_tag and by_tag[p["tag"]]["draft"]: + pending.append((by_tag[p["tag"]], p)) + for release, p in pending: + filename = f"{p['name']}-{p['version']}.zip" + ensure_asset(api, release, filename, payloads[filename]) + ensure_asset(api, release, filename + ".sha256", f"{hashlib.sha256(payloads[filename]).hexdigest()} {filename}\n".encode()) + for release, p, version, original_commit in historical: + api.request("PATCH", f"releases/{release['id']}", {"name": f"{p['name']} v{version} · {p['displayName']}", "body": scope_notice(release.get("body") or "", release["tag_name"], original_commit), "make_latest": "false"}) + for release, _ in pending: + api.request("PATCH", f"releases/{release['id']}", {"draft": False, "make_latest": "false"}) + final = api.request("PATCH", f"releases/{aggregate['id']}", {"name": plan["title"], "body": marketplace_notes(plan, catalog, commit, run_url), "draft": False, "prerelease": False, "make_latest": "true"}) + latest = api.request("GET", "releases/latest") + require(latest["id"] == final["id"], "Latest 未指向市场快照") + require(tag_commit(api, plan["tag"]) == commit, "发布后标签核验失败") + print(json.dumps({"release": final["html_url"], "tag": plan["tag"], "commit": commit, "plugins": [p["name"] for p in catalog], "latestVerified": True}, ensure_ascii=False)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("check", "test-plugins", "build", "publish")) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--commit") + parser.add_argument("--output", type=Path, default=Path("dist/releases")) + args = parser.parse_args() + plan, catalog = load_catalog(args.root) + if args.command == "check": + print(f"{plan['tag']}:{len(catalog)} 个插件,目录/版本/验证范围一致") + elif args.command == "test-plugins": + for p in catalog: + print(f"验证 {p['name']} {p['version']}", flush=True) + subprocess.run(p["validation"]["command"], cwd=args.root / p["path"], check=True, timeout=300, shell=False) + elif args.command == "build": + build(args.root, args.commit, args.output) + else: + require(os.environ.get("GITHUB_ACTIONS") == "true" and os.environ.get("GITHUB_REPOSITORY") == REPOSITORY and os.environ.get("GITHUB_REF") == "refs/heads/main", "只允许本仓库 main 的 Actions 发布") + require(os.environ.get("GITHUB_EVENT_NAME") in ("push", "workflow_dispatch"), "不允许从 PR 等事件发布") + require(args.commit == os.environ.get("GITHUB_SHA"), "发布 SHA 必须等于已验证工作流提交") + token = os.environ.get("GITHUB_TOKEN") + require(token, "缺少 Actions 发布令牌") + publish(args.root, args.commit, args.output, GitHubAPI(token)) + + +if __name__ == "__main__": + try: + main() + except (ValueError, RuntimeError, OSError, subprocess.SubprocessError) as error: + print(f"发布中止:{error}", file=sys.stderr) + sys.exit(1) diff --git a/tests/release/test_release_marketplace.py b/tests/release/test_release_marketplace.py new file mode 100644 index 0000000..5ed9a0a --- /dev/null +++ b/tests/release/test_release_marketplace.py @@ -0,0 +1,315 @@ +"""只用临时 Git 仓库和伪 GitHub API;不联网、不接触用户账号。""" +import copy +import hashlib +import importlib.util +import io +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +import zipfile + +MODULE = Path(__file__).resolve().parents[2] / 'scripts/release_marketplace.py' +spec = importlib.util.spec_from_file_location('release_marketplace', MODULE) +r = importlib.util.module_from_spec(spec) +spec.loader.exec_module(r) + + +class FakeAPI: + def __init__(self): + self.tags, self.releases, self.writes = {}, {}, [] + self.latest = None + + def request(self, method, route, data=None, missing=False, upload=False): + if method != 'GET': + self.writes.append((method, route)) + if route.startswith('git/ref/tags/'): + tag = route[len('git/ref/tags/'):] + return {'object': {'type': 'commit', 'sha': self.tags[tag]}} if tag in self.tags else None + if route == 'git/refs': + self.tags[data['ref'].removeprefix('refs/tags/')] = data['sha'] + return {} + if route.startswith('releases?'): + return copy.deepcopy(list(self.releases.values())) + if route == 'releases/latest': + return copy.deepcopy(self.releases[self.latest]) if self.latest is not None else None + if route == 'releases' and method == 'POST': + number = len(self.releases) + 1 + release = dict(data, id=number, html_url=f'{r.BASE}/releases/tag/{data["tag_name"]}', assets=[]) + self.releases[number] = release + return copy.deepcopy(release) + if route.startswith('releases/'): + number = int(route.split('/')[1]) + if upload: + filename = route.split('?name=')[1] + self.releases[number]['assets'].append({'name': filename, 'digest': 'sha256:' + hashlib.sha256(data).hexdigest()}) + return {} + if method == 'PATCH': + self.releases[number].update(data) + if data.get('make_latest') == 'true': + self.latest = number + return copy.deepcopy(self.releases[number]) + raise AssertionError((method, route)) + + +class ReleaseTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) / 'repo' + self.root.mkdir() + self.output = Path(self.tmp.name) / 'out' + self.market = {'name': 'why-ping', 'plugins': []} + self.plan = {'schemaVersion': 1, 'tag': 'marketplace-2026.09.09', 'title': '市场总览', 'summary': ['测试发布'], 'validation': {}} + for name in ('alpha', 'beta'): + self.market['plugins'].append({'name': name, 'source': {'source': 'local', 'path': './plugins/' + name}}) + self.plan['validation'][name] = {'command': ['node', '--test'], 'scope': '静态测试', 'runtime': '未执行实机验收'} + self.write(f'plugins/{name}/.codex-plugin/plugin.json', {'name': name, 'version': '0.1.0', 'author': {'name': 'Why.Ping'}, 'interface': {'displayName': name + ' 中文'}}) + self.write(f'plugins/{name}/README.md', '# 测试说明\n') + self.write(f'plugins/{name}/CHANGELOG.md', '# 更新\n\n## 0.1.0 — 2026-09-09\n\n新增测试。\n\n## 0.0.1\n\n旧内容。\n') + self.save_config() + self.command('init', '-q') + self.command('config', 'user.email', 'fixture@example.invalid') + self.command('config', 'user.name', 'Release fixture') + self.command('config', 'core.autocrlf', 'false') + self.sha = self.commit() + + def command(self, *args): + return subprocess.check_output(['git', *args], cwd=self.root).decode().strip() + + def write(self, relative, content): + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(content, ensure_ascii=False) + '\n' if isinstance(content, dict) else content, encoding='utf-8', newline='\n') + + def save_config(self): + self.write('.agents/plugins/marketplace.json', self.market) + self.write('releases/marketplace.json', self.plan) + + def commit(self): + self.command('add', '.') + self.command('commit', '-qm', 'fixture') + return self.command('rev-parse', 'HEAD') + + def test_catalog_includes_every_plugin_in_order(self): + _, catalog = r.load_catalog(self.root) + self.assertEqual([p['name'] for p in catalog], ['alpha', 'beta']) + + def test_duplicate_names_rejected(self): + self.market['plugins'].append(self.market['plugins'][0]) + self.save_config() + with self.assertRaises(ValueError): + r.load_catalog(self.root) + + def test_missing_validation_rejected(self): + del self.plan['validation']['beta'] + self.save_config() + with self.assertRaises(ValueError): + r.load_catalog(self.root) + + def test_stale_validation_rejected(self): + self.plan['validation']['unknown'] = self.plan['validation']['alpha'] + self.save_config() + with self.assertRaises(ValueError): + r.load_catalog(self.root) + + def test_path_escape_rejected(self): + self.market['plugins'][0]['source']['path'] = '../../private' + self.save_config() + with self.assertRaises(ValueError): + r.load_catalog(self.root) + + def test_remote_source_rejected(self): + self.market['plugins'][0]['source'] = {'source': 'git', 'url': 'https://example.invalid/a'} + self.save_config() + with self.assertRaises(ValueError): + r.load_catalog(self.root) + + def test_invalid_version_rejected(self): + p = self.root / 'plugins/alpha/.codex-plugin/plugin.json' + data = json.loads(p.read_text(encoding='utf-8')) + data['version'] = '0.1.0+cache' + self.write(str(p.relative_to(self.root)), data) + with self.assertRaises(ValueError): + r.load_catalog(self.root) + + def test_mismatched_manifest_name_rejected(self): + p = self.root / 'plugins/alpha/.codex-plugin/plugin.json' + data = json.loads(p.read_text(encoding='utf-8')) + data['name'] = 'other' + self.write(str(p.relative_to(self.root)), data) + with self.assertRaises(ValueError): + r.load_catalog(self.root) + + def test_missing_changelog_version_rejected(self): + self.write('plugins/alpha/CHANGELOG.md', '# 更新\n## 0.0.1\n旧版\n') + with self.assertRaises(ValueError): + r.load_catalog(self.root) + + def test_current_changelog_only(self): + _, catalog = r.load_catalog(self.root) + self.assertEqual(r.version_changes(self.root, catalog[0]), '新增测试。') + + def test_marketplace_notes_include_both_install_commands_and_limits(self): + plan, catalog = r.load_catalog(self.root) + notes = r.marketplace_notes(plan, catalog, self.sha) + for name in ('alpha', 'beta'): + self.assertIn(f'codex plugin add {name}@why-ping', notes) + self.assertIn('--ref ' + self.sha, notes) + self.assertIn('独立 CODEX_HOME', notes) + self.assertIn('未执行实机验收', notes) + self.assertIn('不是签名或安全认证', notes) + + def test_scope_notice_preserves_history_and_is_idempotent(self): + old = '# 原文\n\nold command\n' + once = r.scope_notice(old, 'alpha-v0.1.0', self.sha) + self.assertTrue(once.endswith(old)) + self.assertEqual(once, r.scope_notice(once, 'alpha-v0.1.0', self.sha)) + + def test_broken_notice_rejected(self): + with self.assertRaises(ValueError): + r.scope_notice(r.START + 'missing end', 'alpha-v0.1.0', self.sha) + + def test_archives_and_checksum_manifest(self): + _, _, payloads = r.build(self.root, self.sha, self.output) + self.assertEqual(len(payloads), 5) + manifest = json.loads(payloads['release-manifest.json']) + self.assertEqual(manifest['commit'], self.sha) + self.assertEqual(len(manifest['plugins']), 2) + for plugin in manifest['plugins']: + blob = payloads[plugin['asset']] + self.assertEqual(hashlib.sha256(blob).hexdigest(), plugin['sha256']) + names = zipfile.ZipFile(io.BytesIO(blob)).namelist() + self.assertTrue(all(n.startswith(plugin['name'] + '/') for n in names)) + self.assertIn(plugin['name'] + '/.codex-plugin/plugin.json', names) + all_names = zipfile.ZipFile(io.BytesIO(payloads[manifest['marketplaceAsset']])).namelist() + self.assertIn('why-ping/.agents/plugins/marketplace.json', all_names) + self.assertIn('why-ping/plugins/beta/.codex-plugin/plugin.json', all_names) + for line in payloads['SHA256SUMS'].decode().splitlines(): + digest, filename = line.split(' ') + self.assertEqual(digest, hashlib.sha256(payloads[filename]).hexdigest()) + + def test_build_reproducible(self): + one = r.build(self.root, self.sha, self.output)[2] + two = r.build(self.root, self.sha, self.output)[2] + self.assertEqual(one, two) + + def test_unrelated_repository_change_preserves_plugin_zip(self): + one = r.build(self.root, self.sha, self.output)[2] + self.write('README.md', '# 市场文档\n') + newer = self.commit() + two = r.build(self.root, newer, self.output)[2] + self.assertEqual(one['alpha-0.1.0.zip'], two['alpha-0.1.0.zip']) + + def test_dirty_checkout_rejected(self): + self.write('plugins/alpha/README.md', 'uncommitted\n') + with self.assertRaises(ValueError): + r.build(self.root, self.sha, self.output) + + def test_incorrect_commit_rejected(self): + with self.assertRaises(ValueError): + r.build(self.root, 'a' * 40, self.output) + + def test_committed_secret_file_rejected(self): + self.write('.env', 'SYNTHETIC=not-a-secret\n') + sha = self.commit() + with self.assertRaises(ValueError): + r.committed_files(self.root, sha) + + def test_committed_symlink_mode_rejected_without_os_symlink_privilege(self): + oid = subprocess.check_output(['git', 'hash-object', '-w', '--stdin'], cwd=self.root, input=b'../../outside').decode().strip() + self.command('update-index', '--add', '--cacheinfo', f'120000,{oid},link') + self.command('commit', '-qm', 'synthetic symlink') + with self.assertRaises(ValueError): + r.committed_files(self.root, self.command('rev-parse', 'HEAD')) + + def test_publish_and_retry_no_duplicate_releases_or_assets(self): + api = FakeAPI() + r.publish(self.root, self.sha, self.output, api) + self.assertEqual(len(api.releases), 3) + self.assertEqual(api.releases[api.latest]['tag_name'], self.plan['tag']) + before = copy.deepcopy(api.tags) + count = sum(len(v['assets']) for v in api.releases.values()) + r.publish(self.root, self.sha, self.output, api) + self.assertEqual(api.tags, before) + self.assertEqual(len(api.releases), 3) + self.assertEqual(sum(len(v['assets']) for v in api.releases.values()), count) + self.assertTrue(all(not v['draft'] for v in api.releases.values())) + for release in api.releases.values(): + if release['tag_name'] != self.plan['tag']: + self.assertEqual(release['body'].count(r.START), 1) + self.assertEqual(release['make_latest'], 'false') + + def test_marketplace_tag_conflict_fails_before_writes(self): + api = FakeAPI() + api.tags[self.plan['tag']] = 'a' * 40 + with self.assertRaises(ValueError): + r.publish(self.root, self.sha, self.output, api) + self.assertEqual(api.writes, []) + + def test_same_plugin_version_cannot_publish_changed_contents(self): + api = FakeAPI() + api.tags['alpha-v0.1.0'] = self.sha + self.write('plugins/alpha/README.md', 'changed\n') + changed = self.commit() + with self.assertRaises(ValueError): + r.publish(self.root, changed, self.output, api) + self.assertEqual(api.writes, []) + + def test_asset_conflict_never_overwritten(self): + api = FakeAPI() + r.publish(self.root, self.sha, self.output, api) + api.releases[api.latest]['assets'][0]['digest'] = 'sha256:incorrect' + api.writes.clear() + with self.assertRaises(ValueError): + r.publish(self.root, self.sha, self.output, api) + self.assertEqual(api.writes, []) + + def test_tag_conflict_never_moved(self): + api = FakeAPI() + api.tags['old'] = 'a' * 40 + with self.assertRaises(ValueError): + r.ensure_tag(api, 'old', self.sha) + self.assertEqual(api.writes, []) + + def test_historical_release_body_and_tag_preserved(self): + api = FakeAPI() + api.tags['alpha-v0.1.0'] = self.sha + old = '# 历史正文\n\n原有更新说明。\n' + release = api.request('POST', 'releases', {'tag_name': 'alpha-v0.1.0', 'name': '旧标题', 'body': old, 'draft': False}) + api.latest = release['id'] + r.publish(self.root, self.sha, self.output, api) + self.assertEqual(api.tags['alpha-v0.1.0'], self.sha) + self.assertTrue(api.releases[release['id']]['body'].endswith(old)) + self.assertEqual(api.releases[release['id']]['name'], 'alpha v0.1.0 · alpha 中文') + + def test_existing_plugin_draft_is_resumed(self): + api = FakeAPI() + api.tags['alpha-v0.1.0'] = self.sha + release = api.request('POST', 'releases', {'tag_name': 'alpha-v0.1.0', 'name': '旧草稿', 'body': '正文', 'draft': True}) + r.publish(self.root, self.sha, self.output, api) + self.assertFalse(api.releases[release['id']]['draft']) + self.assertEqual(len(api.releases[release['id']]['assets']), 2) + self.assertEqual(len(api.releases), 3) + + def test_older_commit_cannot_downgrade_marketplace_latest(self): + api = FakeAPI() + self.write('README.md', 'newer commit\n') + newer = self.commit() + api.tags['marketplace-2026.09.10'] = newer + release = api.request('POST', 'releases', {'tag_name': 'marketplace-2026.09.10', 'name': '新快照', 'body': 'new', 'draft': False}) + api.latest = release['id'] + api.writes.clear() + self.command('checkout', '-q', '--detach', self.sha) + with self.assertRaises(ValueError): + r.publish(self.root, self.sha, self.output, api) + self.assertEqual(api.writes, []) + + def test_credential_redirect_disabled(self): + self.assertIsNone(r.NoRedirect().redirect_request(None, None, 302, '', {}, 'https://example.invalid')) + + +if __name__ == '__main__': + unittest.main()