From bd1f5f86f39820825a4a34d3a5d39398cf86dcc5 Mon Sep 17 00:00:00 2001 From: Cea <61349137+ceastld@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:33:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(release):=20=E5=9B=BA=E5=8C=96=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E6=8F=92=E4=BB=B6=E6=89=93=E5=8C=85=E4=B8=8E=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E6=80=A7=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 11 +- AGENTS.md | 1 + README.md | 2 + "docs/\345\217\221\345\270\203.md" | 56 +++ ...60\345\242\236\345\271\263\345\217\260.md" | 1 + scripts/package-release.py | 223 +++++++++++ scripts/release-packages.json | 36 ++ scripts/sync-packages.py | 10 +- tests/test_release_packages.py | 377 ++++++++++++++++++ 9 files changed, 711 insertions(+), 6 deletions(-) create mode 100644 "docs/\345\217\221\345\270\203.md" create mode 100644 scripts/package-release.py create mode 100644 scripts/release-packages.json create mode 100644 tests/test_release_packages.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e33e1d4..d92c561 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,5 +26,14 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python -m json.tool plugins/quicker/.mcp.json > $null if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Test real Windows PowerShell transport + - name: Test transport, installation and release packaging run: python -m unittest discover -s tests -v + - name: Build and verify every release package + run: | + $quickerCheckTag = "v0.0.0-ci.$env:GITHUB_RUN_ID.$env:GITHUB_RUN_ATTEMPT" + git tag $quickerCheckTag HEAD + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python scripts/package-release.py --tag $quickerCheckTag --output-dir .temp/release-ci + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python scripts/package-release.py --tag $quickerCheckTag --output-dir .temp/release-ci --verify + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/AGENTS.md b/AGENTS.md index a403245..c939021 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ This repository maintains integrations that let external agents use Quicker. Sta - Check git status before editing and preserve unrelated changes. Use one writer for Git mutations. - User-facing communication and documentation are Chinese. Code and Agent-facing instructions may use English. - Write temporary output under .temp/ and distribution archives under dist/; both are ignored. +- Every release must ship every platform's standalone ZIP, the complete source/install ZIP, release-manifest.json and SHA256SUMS.txt, even when only one plugin changed. Follow docs/发布.md and scripts/release-packages.json; build and verify from the tag with scripts/package-release.py. Verify the downloaded release assets as well. Artifact filenames use the release version; plugin manifest versions remain independent. - For transport changes, run `python -m unittest discover -s tests -v` on Windows. The tests use isolated fixtures; never point them at the user's real settings. - Validate changed plugin manifests with that platform's current tooling. For Codex, the installed plugin-creator validator is useful locally; the package must not depend on it at runtime. - For installation changes, verify package discovery and a local install separately from live Quicker acceptance. A mock transport test does not establish real action authoring. diff --git a/README.md b/README.md index 247e96d..e247c02 100644 --- a/README.md +++ b/README.md @@ -165,4 +165,6 @@ python -m unittest discover -s tests -v 新增平台先读[接入约定](docs/接入约定.md)和[新增平台](docs/新增平台.md)。贡献者与 Agent 的仓库工作规则见 [AGENTS.md](AGENTS.md)。 +每次 Release 提供所有平台的独立 ZIP、完整安装包、发布清单和校验和;即使只更新一个插件也不省略其他平台。发布维护者使用[统一打包与验证流程](docs/发布.md),从指定 tag 生成并校验全部附件。 + 参考:[OpenAI 插件打包规范](https://developers.openai.com/plugins/build/plugins)、[Cursor 插件规范](https://cursor.com/docs/reference/plugins)。 diff --git "a/docs/\345\217\221\345\270\203.md" "b/docs/\345\217\221\345\270\203.md" new file mode 100644 index 0000000..749410f --- /dev/null +++ "b/docs/\345\217\221\345\270\203.md" @@ -0,0 +1,56 @@ +# 发布完整插件集合 + +每次 GitHub Release 都必须提供 **Codex、Cursor、Claude Code、通用 MCP 的全部独立 ZIP**,即使本次只修改其中一个插件。最新 Release 应能直接找到所有平台的安装包,不能要求用户到旧版本寻找其他插件。 + +## 单一清单与版本 + +`scripts/release-packages.json` 是安装包和共享资源的唯一名单,供共享源码同步与发布打包使用。每项声明平台 id、包目录、插件 manifest,以及适用的 MCP / marketplace 入口。新增平台时同时更新该清单;打包器会核对名单与 tag 下所有受跟踪 `plugins/<目录>` 一致。 + +文件名统一使用发布批次版本,包内 manifest 保留平台自己的版本。例如批次 `v0.2.2` 会生成 `quicker-cursor-0.2.2.zip`,即使其中的 Cursor 插件仍为 `0.2.0`。不要为凑齐附件擅自递增未变更的插件版本。`release-manifest.json` 分别记录 `releaseVersion`、各包 `packageVersion`、源 tag / commit、附件名、大小和 SHA-256。 + +当前每批必须有 7 个附件: + +- `quicker-agent-integrations-<发布版本>.zip`:完整源码安装包,含各平台包、安装器、市场入口、文档与许可证。 +- `quicker-codex-<发布版本>.zip`、`quicker-cursor-<发布版本>.zip`、`quicker-claude-<发布版本>.zip`、`quicker-mcp-<发布版本>.zip`:所有独立包。 +- `release-manifest.json`:可审查的批次与包版本记录。 +- `SHA256SUMS.txt`:覆盖全部 ZIP 及发布清单。 + +独立 ZIP 的根目录统一为 `quicker/`,包含隐藏 manifest、必需脚本、技能及该 tag 的 MIT 许可证。完整包根目录为 `quicker-agent-integrations-<发布版本>/`。 + +## 生成与验证 + +先提交变更、完成 PR / CI 和本次发布授权,再选定发布 tag。将以下 `v0.2.2` 替换为实际已存在的本地 SemVer tag;打包脚本不会创建 tag、上传文件或修改 Release。 + +```powershell +git fetch origin --tags +python scripts/package-release.py --tag v0.2.2 +python scripts/package-release.py --tag v0.2.2 --verify +``` + +默认输出到 `dist/v0.2.2/`,可用 `--output-dir` 指定新目录。生成要求输出目录为空,避免混入上次附件或覆盖已有文件。校验模式只读,检查附件集合、ZIP 路径、文件内容和权限、tag 中的版本、完整校验和。缺包、清单遗漏受跟踪平台、共享副本漂移、缺少必需入口或附件内容与 tag 不同都会失败。 + +打包内容直接读取 tag 的 Git tree / blob,保留隐藏文件,不读取工作区中的修改、未跟踪文件或 ignored `plugins/codex` 等工具产物。若 `.temp/`、`dist/`、`.quicker/`、`.venv/` 或 `__pycache__/` 根目录被误提交到 tag,脚本会拒绝打包。历史上尚未包含发布清单的 tag 不适用此流程;不要将当前工作区文件冒充旧 tag 内容。 + +CI 在自己的临时检出中创建本地测试 tag,实际打出整套附件并再次校验;不会推送测试 tag 或创建 Release。另有隔离 Git fixture 测试,覆盖全部平台、版本区别、标签内容隔离以及篡改检测。 + +## 上传与发布后检查 + +核对生成的 `release-manifest.json` 和发布说明,再按清单上传全部附件,不手工挑选“本次变化”的平台。以下命令从清单构造附件列表;发布说明应提前写在 `.temp/`: + +```powershell +$quickerReleaseDir = Join-Path (Get-Location) 'dist/v0.2.2' +$quickerRelease = Get-Content (Join-Path $quickerReleaseDir 'release-manifest.json') -Raw | ConvertFrom-Json +$quickerAssets = @($quickerRelease.artifacts | ForEach-Object { Join-Path $quickerReleaseDir $_.file }) +$quickerAssets += Join-Path $quickerReleaseDir 'release-manifest.json' +$quickerAssets += Join-Path $quickerReleaseDir 'SHA256SUMS.txt' +gh release create $quickerRelease.tag @quickerAssets --target $quickerRelease.sourceCommit --title "Quicker Agent Integrations $($quickerRelease.tag)" --notes-file .temp/release-notes.md +``` + +上传后下载全部附件到另一个新目录,再以同一 tag 校验,确认远端 Release 没有漏项: + +```powershell +gh release download v0.2.2 --dir dist/v0.2.2-downloaded +python scripts/package-release.py --tag v0.2.2 --output-dir dist/v0.2.2-downloaded --verify +``` + +已有 Release 缺附件时,以原 tag 内容补齐完整集合并更新清单和校验和;不要移动已公开的 tag。发布包完整性、平台安装和真实 Quicker 写动作验收分别记录,不能相互替代。 diff --git "a/docs/\346\226\260\345\242\236\345\271\263\345\217\260.md" "b/docs/\346\226\260\345\242\236\345\271\263\345\217\260.md" index 6519eba..82fa409 100644 --- "a/docs/\346\226\260\345\242\236\345\271\263\345\217\260.md" +++ "b/docs/\346\226\260\345\242\236\345\271\263\345\217\260.md" @@ -15,6 +15,7 @@ | 项目 | 完成要求 | | --- | --- | | 安装单元 | 独立包目录中包含清单、MCP 配置、流程引导、必需脚本及引用文件,并在该平台市场清单中声明路径。使用平台支持的包内路径机制,保留隐藏配置文件。 | +| 发布集合 | 将平台登记到 `scripts/release-packages.json`,确保以后每批 Release 都包含其独立 ZIP;包版本与发布批次分别记录,遵循[发布约定](发布.md)。 | | 安装与更新 | 提供可执行命令或已验证的官方安装步骤,列出安装期与运行期依赖、更新、卸载和重新加载方式。安装仅修改本插件受管内容,保留用户其他配置。 | | 平台引导 | 正确路由 Quicker 工具,解释虚拟路径、显式 slot、运行时知识、草稿默认结果和错误恢复;将平台特有提示留在本平台。 | | 自动验证 | 校验清单和引用文件,运行传输契约测试,覆盖平台启动参数、路径解析及安装包独立运行。 | diff --git a/scripts/package-release.py b/scripts/package-release.py new file mode 100644 index 0000000..3fa6db5 --- /dev/null +++ b/scripts/package-release.py @@ -0,0 +1,223 @@ +"""Build and verify every release asset from one immutable Git tag snapshot.""" +import argparse +import hashlib +import io +import json +from pathlib import Path, PurePosixPath +import re +import subprocess +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +INVENTORY = 'scripts/release-packages.json' +NUMBER = r'(?:0|[1-9][0-9]*)' +PRERELEASE_ID = r'(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)' +VERSION = NUMBER + r'\.' + NUMBER + r'\.' + NUMBER + r'(?:-' + PRERELEASE_ID + r'(?:\.' + PRERELEASE_ID + r')*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?' + + +def git(repo, *args, data=None): + result = subprocess.run(['git', '-C', str(repo), *args], input=data, capture_output=True) + if result.returncode: + raise RuntimeError(result.stderr.decode('utf-8', errors='replace').strip()) + return result.stdout + + +def relative_path(value): + if not isinstance(value, str) or not value or '\\' in value or ':' in value: + raise ValueError(f'Invalid relative path: {value!r}') + parts = value.split('/') + if any(part in ('', '.', '..') for part in parts) or PurePosixPath(value).is_absolute(): + raise ValueError(f'Invalid relative path: {value!r}') + return value + + +def snapshot(repo, tag): + match = re.fullmatch('v?(' + VERSION + ')', tag) + if not match: + raise ValueError('Use a SemVer tag, for example v0.2.2.') + commit = git(repo, 'rev-parse', '--verify', 'refs/tags/' + tag + '^{commit}').decode().strip() + entries = [] + for entry in git(repo, 'ls-tree', '-r', '-z', commit).split(b'\0'): + if not entry: + continue + header, raw_path = entry.split(b'\t', 1) + mode, kind, oid = header.decode('ascii').split() + path = relative_path(raw_path.decode('utf-8')) + if kind != 'blob' or mode not in ('100644', '100755'): + raise ValueError(f'Release requires ordinary tracked files: {path}') + if path.split('/')[0] in ('.temp', 'dist', '.quicker', '.venv', '__pycache__'): + raise ValueError(f'Temporary or personal data is tracked in the tag: {path}') + entries.append((path, mode, oid)) + oids = list(dict.fromkeys(oid for _, _, oid in entries)) + batch = io.BytesIO(git(repo, 'cat-file', '--batch', data=('\n'.join(oids) + '\n').encode())) + blobs = {} + for expected in oids: + oid, kind, length = batch.readline().decode('ascii').strip().split() + contents = batch.read(int(length)) + if oid != expected or kind != 'blob' or len(contents) != int(length) or batch.read(1) != b'\n': + raise ValueError('Invalid Git object stream.') + blobs[oid] = contents + files = {path: (blobs[oid], int(mode, 8)) for path, mode, oid in entries} + return commit, match.group(1), files + + +def read_json(files, path): + if path not in files: + raise ValueError(f'Missing tracked file: {path}') + try: + return json.loads(files[path][0]) + except (ValueError, UnicodeDecodeError) as error: + raise ValueError(f'Invalid JSON: {path}') from error + + +def release_plan(repo, tag): + commit, version, files = snapshot(repo, tag) + inventory = read_json(files, INVENTORY) + if not isinstance(inventory, dict) or inventory.get('schemaVersion') != 1 or not isinstance(inventory.get('packages'), list) or not inventory['packages']: + raise ValueError('Release inventory must contain schemaVersion 1 and all packages.') + if 'LICENSE' not in files: + raise ValueError('The tag must contain the repository LICENSE.') + if not isinstance(inventory.get('sharedFiles'), list) or not inventory['sharedFiles']: + raise ValueError('Release inventory must declare the required shared files.') + shared = [relative_path(path) for path in inventory['sharedFiles']] + paths, ids = set(), set() + full_root = f'quicker-agent-integrations-{version}/' + plan = [({'file': f'quicker-agent-integrations-{version}.zip', 'kind': 'source', 'root': full_root}, files)] + for package in inventory['packages']: + package_id, path = package['id'], relative_path(package['path']) + if not re.fullmatch('[a-z][a-z0-9-]*', package_id) or package_id in ids: + raise ValueError(f'Invalid or duplicate package id: {package_id}') + if not re.fullmatch('plugins/[a-z0-9-]+', path) or path in paths: + raise ValueError(f'Invalid or duplicate package path: {path}') + ids.add(package_id) + paths.add(path) + manifest_path = relative_path(package['manifest']) + manifest = read_json(files, path + '/' + manifest_path) + package_version = manifest.get('version') + if not isinstance(package_version, str) or not re.fullmatch(VERSION, package_version): + raise ValueError(f'Missing or invalid package version: {path}') + if not isinstance(manifest.get('name'), str) or not manifest['name']: + raise ValueError(f'Missing package name: {path}') + required = ['README.md', manifest_path, *shared] + if package.get('mcpConfig'): + required.append(relative_path(package['mcpConfig'])) + for relative in required: + if path + '/' + relative not in files: + raise ValueError(f'Missing required package file: {path}/{relative}') + for relative in shared: + if 'shared/' + relative not in files or files[path + '/' + relative][0] != files['shared/' + relative][0]: + raise ValueError(f'Shared source drift: {path}/{relative}') + if package.get('marketplace'): + marketplace = read_json(files, relative_path(package['marketplace'])) + sources = [] + for entry in marketplace.get('plugins', []): + source = entry.get('source') + source = source.get('path') if isinstance(source, dict) else source + sources.append((entry.get('name'), source)) + if (manifest['name'], './' + path) not in sources: + raise ValueError(f'Marketplace does not expose the package: {path}') + package_files = {name[len(path) + 1:]: value for name, value in files.items() if name.startswith(path + '/')} + if 'LICENSE' in package_files and package_files['LICENSE'][0] != files['LICENSE'][0]: + raise ValueError(f'Package LICENSE differs from the repository notice: {path}') + package_files['LICENSE'] = files['LICENSE'] + plan.append(({ + 'file': f'quicker-{package_id}-{version}.zip', 'kind': 'plugin', 'root': 'quicker/', + 'id': package_id, 'path': path, 'manifest': manifest_path, + 'packageName': manifest['name'], 'packageVersion': package_version, + }, package_files)) + tracked_packages = {'/'.join(path.split('/')[:2]) for path in files if path.startswith('plugins/') and path.count('/') >= 2} + if paths != tracked_packages: + raise ValueError(f'Inventory must cover all tracked packages; missing={sorted(tracked_packages - paths)}, absent={sorted(paths - tracked_packages)}') + return commit, version, plan + + +def json_bytes(value): + return (json.dumps(value, ensure_ascii=False, indent=2) + '\n').encode('utf-8') + + +def asset_metadata(descriptor, path): + contents = path.read_bytes() + return {**descriptor, 'sha256': hashlib.sha256(contents).hexdigest(), 'size': len(contents)} + + +def expected_manifest(tag, commit, version, artifacts): + return {'schemaVersion': 1, 'tag': tag, 'sourceCommit': commit, 'releaseVersion': version, 'artifacts': artifacts} + + +def checksums(output, names): + return ''.join(hashlib.sha256((output / name).read_bytes()).hexdigest() + ' ' + name + '\n' for name in sorted(names)) + + +def build_release(repo, tag, output): + output = Path(output) + commit, version, plan = release_plan(repo, tag) + if output.exists() and any(output.iterdir()): + raise ValueError('Output directory must be empty; existing release assets will not be overwritten.') + output.mkdir(parents=True, exist_ok=True) + artifacts = [] + for descriptor, files in plan: + target = output / descriptor['file'] + with zipfile.ZipFile(target, 'x', compression=zipfile.ZIP_DEFLATED) as archive: + for path, (contents, mode) in sorted(files.items()): + info = zipfile.ZipInfo(descriptor['root'] + path, date_time=(1980, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = mode << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, contents) + artifacts.append(asset_metadata(descriptor, target)) + manifest = expected_manifest(tag, commit, version, artifacts) + (output / 'release-manifest.json').write_bytes(json_bytes(manifest)) + names = [artifact['file'] for artifact in artifacts] + ['release-manifest.json'] + (output / 'SHA256SUMS.txt').write_bytes(checksums(output, names).encode('utf-8')) + return verify_release(repo, tag, output) + + +def verify_release(repo, tag, output): + output = Path(output) + commit, version, plan = release_plan(repo, tag) + names = {descriptor['file'] for descriptor, _ in plan} + expected = names | {'release-manifest.json', 'SHA256SUMS.txt'} + if not output.is_dir() or {path.name for path in output.iterdir()} != expected: + raise ValueError('Release assets are missing or unexpected; every package ZIP, source ZIP, manifest and checksums are required.') + artifacts = [] + for descriptor, files in plan: + target = output / descriptor['file'] + try: + with zipfile.ZipFile(target) as archive: + expected_names = {descriptor['root'] + path for path in files} + if len(archive.namelist()) != len(expected_names) or set(archive.namelist()) != expected_names: + raise ValueError(f'ZIP entries do not match the tag: {target.name}') + for path, (contents, mode) in files.items(): + name = descriptor['root'] + path + if archive.read(name) != contents or archive.getinfo(name).external_attr >> 16 != mode: + raise ValueError(f'ZIP content differs from the tag: {target.name}: {path}') + except (zipfile.BadZipFile, OSError) as error: + raise ValueError(f'Invalid ZIP: {target.name}') from error + artifacts.append(asset_metadata(descriptor, target)) + manifest = expected_manifest(tag, commit, version, artifacts) + if (output / 'release-manifest.json').read_bytes() != json_bytes(manifest): + raise ValueError('release-manifest.json does not match the tag and assets.') + if (output / 'SHA256SUMS.txt').read_bytes() != checksums(output, names | {'release-manifest.json'}).encode('utf-8'): + raise ValueError('SHA256SUMS.txt does not cover the exact release assets.') + return manifest + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--tag', required=True, help='Existing local SemVer tag, for example v0.2.2') + parser.add_argument('--output-dir', type=Path, help='Defaults to dist/') + parser.add_argument('--verify', action='store_true', help='Verify existing assets without writing') + args = parser.parse_args() + output = args.output_dir or ROOT / 'dist' / args.tag + try: + manifest = (verify_release if args.verify else build_release)(ROOT, args.tag, output) + except (ValueError, RuntimeError, OSError, KeyError, TypeError) as error: + parser.exit(1, f'Release packaging failed: {error}\n') + print(f"Verified {len(manifest['artifacts'])} ZIPs for {args.tag} at {manifest['sourceCommit']}") + for artifact in manifest['artifacts']: + print(artifact['file']) + print('release-manifest.json\nSHA256SUMS.txt') + + +if __name__ == '__main__': + main() diff --git a/scripts/release-packages.json b/scripts/release-packages.json new file mode 100644 index 0000000..a0ff8cf --- /dev/null +++ b/scripts/release-packages.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "sharedFiles": [ + "scripts/quicker-mcp.ps1", + "skills/write-action/SKILL.md", + "skills/write-action/references/connection.md" + ], + "packages": [ + { + "id": "codex", + "path": "plugins/quicker", + "manifest": ".codex-plugin/plugin.json", + "mcpConfig": ".mcp.json", + "marketplace": ".agents/plugins/marketplace.json" + }, + { + "id": "cursor", + "path": "plugins/quicker-cursor", + "manifest": ".cursor-plugin/plugin.json", + "mcpConfig": "mcp.json", + "marketplace": ".cursor-plugin/marketplace.json" + }, + { + "id": "claude", + "path": "plugins/quicker-claude", + "manifest": ".claude-plugin/plugin.json", + "mcpConfig": ".mcp.json", + "marketplace": ".claude-plugin/marketplace.json" + }, + { + "id": "mcp", + "path": "plugins/quicker-mcp", + "manifest": "package.json" + } + ] +} diff --git a/scripts/sync-packages.py b/scripts/sync-packages.py index 8c3e5e0..3980c0b 100644 --- a/scripts/sync-packages.py +++ b/scripts/sync-packages.py @@ -1,18 +1,18 @@ """Materialize shared sources into self-contained client packages.""" import argparse +import json from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -PACKAGES = ("quicker", "quicker-cursor", "quicker-claude", "quicker-mcp") -FILES = ("scripts/quicker-mcp.ps1", "skills/write-action/SKILL.md", "skills/write-action/references/connection.md") +INVENTORY = json.loads((ROOT / "scripts/release-packages.json").read_text(encoding="utf-8")) def sync(check=False): stale = [] - for package in PACKAGES: - for relative in FILES: + for package in INVENTORY["packages"]: + for relative in INVENTORY["sharedFiles"]: source = (ROOT / "shared" / relative).read_bytes() - target = ROOT / "plugins" / package / relative + target = ROOT / package["path"] / relative if not target.exists() or target.read_bytes() != source: stale.append(str(target.relative_to(ROOT))) if not check: diff --git a/tests/test_release_packages.py b/tests/test_release_packages.py new file mode 100644 index 0000000..0035246 --- /dev/null +++ b/tests/test_release_packages.py @@ -0,0 +1,377 @@ +"""Build release archives from isolated Git tags, never from a developer's checkout.""" +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +TEMP_ROOT = ROOT / ".temp" +SCRIPT = ROOT / "scripts/package-release.py" +TAG = "v1.2.3" +RELEASE_VERSION = "1.2.3" +SHARED_FILES = ( + "scripts/quicker-mcp.ps1", + "skills/write-action/SKILL.md", + "skills/write-action/references/connection.md", +) +PACKAGES = ( + {"id": "codex", "path": "plugins/quicker", "manifest": ".codex-plugin/plugin.json", + "mcpConfig": ".mcp.json", "marketplace": ".agents/plugins/marketplace.json"}, + {"id": "cursor", "path": "plugins/quicker-cursor", "manifest": ".cursor-plugin/plugin.json", + "mcpConfig": "mcp.json", "marketplace": ".cursor-plugin/marketplace.json"}, + {"id": "claude", "path": "plugins/quicker-claude", "manifest": ".claude-plugin/plugin.json", + "mcpConfig": ".mcp.json", "marketplace": ".claude-plugin/marketplace.json"}, + {"id": "mcp", "path": "plugins/quicker-mcp", "manifest": "package.json"}, +) +PACKAGE_VERSIONS = {"codex": "0.2.0", "cursor": "0.2.0", "claude": "0.2.1", "mcp": "0.2.0"} +EXPECTED_ZIPS = {"quicker-agent-integrations-1.2.3.zip"} | { + f"quicker-{package['id']}-1.2.3.zip" for package in PACKAGES +} + + +def json_bytes(value): + return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8") + + +def sha256(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +@unittest.skipUnless(shutil.which("git"), "Git required for isolated release fixtures") +class ReleasePackageTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + spec = importlib.util.spec_from_file_location("quicker_package_release", SCRIPT) + cls.release = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.release) + + def setUp(self): + TEMP_ROOT.mkdir(exist_ok=True) + self.temp = tempfile.TemporaryDirectory(prefix="release-test-", dir=TEMP_ROOT) + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.repo = self.base / "repo" + self.repo.mkdir() + self.output = self.base / "dist" + self.git("init", "--quiet") + self.files = { + ".gitignore": b".temp/\ndist/\nplugins/codex/\n", + "README.md": "Tagged source; 插件版本与发布版本分开。\n".encode("utf-8"), + "LICENSE": b"Fixture root license\n", + "docs/release.md": b"A tracked document belongs in the source archive.\n", + "scripts/release-packages.json": json_bytes({ + "schemaVersion": 1, + "sharedFiles": list(SHARED_FILES), + "packages": list(PACKAGES), + }), + } + for relative in SHARED_FILES: + self.files["shared/" + relative] = f"Tagged shared content: {relative}\n".encode() + for package in PACKAGES: + prefix = package["path"] + "/" + self.files[prefix + "README.md"] = f"Install {package['id']} from this package.\n".encode() + self.files[prefix + package["manifest"]] = json_bytes({ + "name": "quicker-mcp" if package["id"] == "mcp" else "quicker", + "version": PACKAGE_VERSIONS[package["id"]], + }) + for relative in SHARED_FILES: + self.files[prefix + relative] = self.files["shared/" + relative] + if "mcpConfig" in package: + self.files[prefix + package["mcpConfig"]] = json_bytes({ + "mcpServers": {"quicker": {"command": "powershell.exe"}} + }) + if "marketplace" in package: + source = "./" + package["path"] + if package["id"] == "codex": + source = {"source": "local", "path": source} + self.files[package["marketplace"]] = json_bytes({ + "name": "fixture-marketplace", + "owner": {"name": "Fixture"}, + "plugins": [{"name": "quicker", "source": source}], + }) + # A committed license and a missing package license must yield the same root notice. + self.files["plugins/quicker/LICENSE"] = self.files["LICENSE"] + for relative, content in self.files.items(): + self.write(relative, content) + self.commit("Initial tagged fixture") + self.git("tag", TAG) + self.source_commit = self.git("rev-parse", "HEAD") + + def git(self, *args): + # Ignore a caller's Git environment so no operation can escape this fixture repository. + env = {key: value for key, value in os.environ.items() if not key.upper().startswith("GIT_")} + result = subprocess.run( + ["git", "-C", str(self.repo), "-c", "core.autocrlf=false", + "-c", f"core.hooksPath={self.base / 'no-hooks'}", "-c", "commit.gpgSign=false", + "-c", "tag.gpgSign=false", "-c", "user.name=Release Fixture", + "-c", "user.email=fixture@example.invalid", *args], + check=True, capture_output=True, text=True, encoding="utf-8", env=env, timeout=30, + ) + return result.stdout.strip() + + def write(self, relative, content): + path = self.repo / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + + def commit(self, message): + self.git("add", "--all") + self.git("commit", "--quiet", "-m", message) + + def tag_changes(self, tag="v1.2.4"): + self.commit("Changed release fixture") + self.git("tag", tag) + return tag + + def build(self, tag=TAG, output=None): + return self.release.build_release(self.repo, tag, output or self.output) + + def verify(self, tag=TAG): + return self.release.verify_release(self.repo, tag, self.output) + + def assert_rejected(self, action): + with self.assertRaises((ValueError, RuntimeError)): + action() + + def resign_checksums(self): + # Deliberately re-sign tampered metadata: verification must still bind it to the Git tag. + paths = sorted([*self.output.glob("*.zip"), self.output / "release-manifest.json"]) + (self.output / "SHA256SUMS.txt").write_text( + "".join(f"{sha256(path)} {path.name}\n" for path in paths), encoding="utf-8" + ) + + def test_all_four_plugins_are_packaged_even_with_different_unchanged_versions(self): + manifest = self.build() + self.assertEqual(manifest["schemaVersion"], 1) + self.assertEqual(manifest["tag"], TAG) + self.assertEqual(manifest["sourceCommit"], self.source_commit) + self.assertEqual(manifest["releaseVersion"], RELEASE_VERSION) + self.assertEqual(set(path.name for path in self.output.iterdir()), + EXPECTED_ZIPS | {"release-manifest.json", "SHA256SUMS.txt"}) + artifacts = manifest["artifacts"] + self.assertEqual(len(artifacts), 5) + plugins = {artifact["id"]: artifact for artifact in artifacts if artifact["kind"] == "plugin"} + self.assertEqual(set(plugins), {"codex", "cursor", "claude", "mcp"}) + for package in PACKAGES: + with self.subTest(package=package["id"]): + artifact = plugins[package["id"]] + self.assertEqual(artifact["packageVersion"], PACKAGE_VERSIONS[package["id"]]) + self.assertEqual(artifact["packageName"], "quicker-mcp" if package["id"] == "mcp" else "quicker") + self.assertEqual(artifact["path"], package["path"]) + self.assertEqual(artifact["manifest"], package["manifest"]) + self.assertEqual(artifact["root"], "quicker/") + archive_path = self.output / artifact["file"] + self.assertEqual(artifact["size"], archive_path.stat().st_size) + self.assertEqual(artifact["sha256"], sha256(archive_path)) + with zipfile.ZipFile(archive_path) as archive: + package_files = {path.removeprefix(package["path"] + "/"): content + for path, content in self.files.items() + if path.startswith(package["path"] + "/")} + package_files.setdefault("LICENSE", self.files["LICENSE"]) + members = {name for name in archive.namelist() if not name.endswith("/")} + self.assertEqual(members, {"quicker/" + path for path in package_files}) + for path, content in package_files.items(): + self.assertEqual(archive.read("quicker/" + path), content) + self.assertEqual(self.verify(), manifest) + + def test_source_and_plugins_use_only_tagged_files_despite_new_head_and_dirty_checkout(self): + self.write("README.md", b"Committed after the release tag\n") + self.write("shared/scripts/quicker-mcp.ps1", b"Changed after the tag\n") + self.commit("Changes after tag must not enter the release") + self.write("scripts/release-packages.json", b"invalid dirty inventory") + self.write("plugins/quicker/README.md", b"Dirty package document") + self.write("plugins/untracked/secret.txt", b"untracked") + self.write("plugins/codex/private.txt", b"ignored legacy staging output") + self.write(".temp/private.txt", b"ignored temporary output") + manifest = self.build() + source = next(item for item in manifest["artifacts"] if item["kind"] == "source") + self.assertEqual(source["file"], "quicker-agent-integrations-1.2.3.zip") + self.assertEqual(source["root"], "quicker-agent-integrations-1.2.3/") + with zipfile.ZipFile(self.output / source["file"]) as archive: + members = {name for name in archive.namelist() if not name.endswith("/")} + self.assertEqual(members, {source["root"] + path for path in self.files}) + for path, content in self.files.items(): + self.assertEqual(archive.read(source["root"] + path), content) + for package in PACKAGES: + with zipfile.ZipFile(self.output / f"quicker-{package['id']}-1.2.3.zip") as archive: + self.assertEqual(archive.read("quicker/README.md"), self.files[package["path"] + "/README.md"]) + self.assertEqual(self.verify()["sourceCommit"], self.source_commit) + + def test_checksums_cover_exactly_all_archives_and_release_manifest(self): + self.build() + checksums = {} + for line in (self.output / "SHA256SUMS.txt").read_text(encoding="utf-8").splitlines(): + digest, name = line.split(" ", 1) + self.assertNotIn(name, checksums) + self.assertRegex(digest, r"^[0-9a-f]{64}$") + checksums[name] = digest + self.assertEqual(set(checksums), EXPECTED_ZIPS | {"release-manifest.json"}) + for name, digest in checksums.items(): + self.assertEqual(digest, sha256(self.output / name)) + + def test_build_accepts_empty_directory_but_never_overwrites_existing_output(self): + self.output.mkdir() + self.build() + sentinel = self.output / "do-not-overwrite.txt" + sentinel.write_bytes(b"keep") + manifest_before = (self.output / "release-manifest.json").read_bytes() + self.assert_rejected(self.build) + self.assertEqual(sentinel.read_bytes(), b"keep") + self.assertEqual((self.output / "release-manifest.json").read_bytes(), manifest_before) + + def test_tag_is_required_and_cannot_be_a_branch_or_arbitrary_git_expression(self): + self.git("branch", "9.8.7") + for tag in ("9.8.7", "v8.7.6", "HEAD", "", "../v1.2.3", "v1.2", "v1.2.3^", "refs/tags/v1.2.3"): + with self.subTest(tag=tag): + self.assert_rejected(lambda: self.build(tag)) + + def test_existing_tags_with_invalid_semver_identifiers_are_rejected(self): + for tag in ("v1.2.3-01", "v1.2.3-alpha.01", "v1.2.3-", "v1.2.3+"): + with self.subTest(tag=tag): + self.git("tag", tag) + self.assert_rejected(lambda: self.build(tag, self.base / tag)) + + def test_annotated_prerelease_tag_without_v_keeps_release_and_package_versions_separate(self): + tag = "1.2.3-rc.1+build.7" + self.git("tag", "--annotate", tag, "--message", "Fixture prerelease") + manifest = self.build(tag) + self.assertEqual(manifest["tag"], tag) + self.assertEqual(manifest["releaseVersion"], tag) + self.assertEqual(manifest["sourceCommit"], self.source_commit) + self.assertEqual({item["packageVersion"] for item in manifest["artifacts"] if item["kind"] == "plugin"}, + {"0.2.0", "0.2.1"}) + self.assertEqual(self.verify(tag), manifest) + + def test_missing_archive_and_extra_publishable_file_are_rejected(self): + self.build() + archive = self.output / "quicker-cursor-1.2.3.zip" + contents = archive.read_bytes() + archive.unlink() + self.assert_rejected(self.verify) + archive.write_bytes(contents) + (self.output / "quicker-legacy-1.2.3.zip").write_bytes(b"unexpected publication") + self.assert_rejected(self.verify) + + def test_tampered_archive_is_rejected(self): + self.build() + archive = self.output / "quicker-mcp-1.2.3.zip" + with archive.open("ab") as stream: + stream.write(b"tampered") + self.assert_rejected(self.verify) + + def test_tampered_or_incomplete_checksum_list_is_rejected(self): + self.build() + path = self.output / "SHA256SUMS.txt" + original = path.read_text(encoding="utf-8") + path.write_text("0" * 64 + original[64:], encoding="utf-8") + self.assert_rejected(self.verify) + path.write_text("\n".join(original.splitlines()[1:]) + "\n", encoding="utf-8") + self.assert_rejected(self.verify) + + def test_resigned_manifest_metadata_or_missing_plugin_is_rejected(self): + manifest = self.build() + manifest_path = self.output / "release-manifest.json" + mutations = ( + lambda value: value.update(sourceCommit="0" * 40), + lambda value: value.update(releaseVersion="7.7.7"), + lambda value: value.update(tag="v7.7.7"), + lambda value: value["artifacts"].pop(), + lambda value: next(item for item in value["artifacts"] if item["kind"] == "plugin").update(packageVersion="7.7.7"), + lambda value: next(item for item in value["artifacts"] if item["kind"] == "plugin").update(root="wrong/"), + ) + for index, mutate in enumerate(mutations): + with self.subTest(mutation=index): + changed = json.loads(json.dumps(manifest)) + mutate(changed) + manifest_path.write_bytes(json_bytes(changed)) + self.resign_checksums() + self.assert_rejected(self.verify) + + def test_archive_with_resigned_hashes_must_still_match_tagged_contents(self): + manifest = self.build() + artifact = next(item for item in manifest["artifacts"] if item.get("id") == "claude") + archive_path = self.output / artifact["file"] + with zipfile.ZipFile(archive_path) as archive: + contents = [(info, archive.read(info)) for info in archive.infolist()] + with zipfile.ZipFile(archive_path, "w") as archive: + for info, content in contents: + if info.filename == "quicker/README.md": + content = b"Replaced content with matching attacker-controlled hashes" + archive.writestr(info, content) + artifact.update(sha256=sha256(archive_path), size=archive_path.stat().st_size) + (self.output / "release-manifest.json").write_bytes(json_bytes(manifest)) + self.resign_checksums() + self.assert_rejected(self.verify) + + def test_inventory_cannot_silently_omit_a_tracked_plugin(self): + path = "scripts/release-packages.json" + inventory = json.loads(self.files[path]) + inventory["packages"] = inventory["packages"][:-1] + self.write(path, json_bytes(inventory)) + tag = self.tag_changes() + self.assert_rejected(lambda: self.build(tag)) + + def test_inventory_cannot_claim_a_package_missing_from_tag(self): + for relative in self.files: + if relative.startswith("plugins/quicker-mcp/"): + (self.repo / relative).unlink() + tag = self.tag_changes() + self.assert_rejected(lambda: self.build(tag)) + + def test_required_readme_manifest_shared_file_and_declared_mcp_config_are_checked(self): + required = ( + "plugins/quicker-cursor/README.md", + "plugins/quicker-claude/.claude-plugin/plugin.json", + "plugins/quicker-mcp/skills/write-action/SKILL.md", + "plugins/quicker/.mcp.json", + ) + for index, relative in enumerate(required): + with self.subTest(path=relative): + (self.repo / relative).unlink() + tag = self.tag_changes(f"v1.2.{index + 4}") + self.assert_rejected(lambda: self.build(tag, self.base / f"missing-{index}")) + self.write(relative, self.files[relative]) + self.commit("Restore required fixture file") + + def test_shared_copy_drift_is_rejected(self): + self.write("plugins/quicker-cursor/scripts/quicker-mcp.ps1", b"Outdated copied transport\n") + tag = self.tag_changes() + self.assert_rejected(lambda: self.build(tag)) + + def test_different_package_license_cannot_replace_repository_notice(self): + self.write("plugins/quicker/LICENSE", b"An unrelated package notice\n") + tag = self.tag_changes() + self.assert_rejected(lambda: self.build(tag)) + + def test_marketplace_must_point_at_its_inventory_package(self): + for index, package in enumerate(PACKAGES[:3]): + with self.subTest(package=package["id"]): + path = package["marketplace"] + market = json.loads(self.files[path]) + wrong_source = "./plugins/quicker-mcp" + if package["id"] == "codex": + wrong_source = {"source": "local", "path": wrong_source} + market["plugins"][0]["source"] = wrong_source + self.write(path, json_bytes(market)) + tag = self.tag_changes(f"v1.2.{index + 4}") + self.assert_rejected(lambda: self.build(tag, self.base / f"marketplace-{index}")) + self.write(path, self.files[path]) + self.commit("Restore fixture marketplace") + + def test_moved_tag_cannot_validate_old_release(self): + self.build() + self.write("README.md", b"The tag now points at a different commit\n") + self.commit("Move fixture release forward") + self.git("tag", "--force", TAG) + self.assert_rejected(self.verify) + + +if __name__ == "__main__": + unittest.main()