From 68b7164df8eea673f328e0b5746bce460ba60147 Mon Sep 17 00:00:00 2001 From: render Date: Wed, 9 Sep 2026 17:28:28 +0800 Subject: [PATCH] feat: prepare versioned production Curio releases --- README.md | 57 +++----------- docs/development.md | 60 ++++++++++++++ TESTING.md => docs/testing.md | 16 +++- docs/versioning.md | 68 ++++++++++++++++ package.json | 4 +- tools/host-check.rs | 138 +++++++++++++++++++++++++++++++++ tools/prepare-release.mjs | 69 +++++++++++++++++ tools/release-catalog.mjs | 37 +++++++++ tools/release-catalog.test.mjs | 46 +++++++++++ tools/verify.mjs | 20 ++++- 10 files changed, 461 insertions(+), 54 deletions(-) create mode 100644 docs/development.md rename TESTING.md => docs/testing.md (89%) create mode 100644 docs/versioning.md create mode 100644 tools/prepare-release.mjs create mode 100644 tools/release-catalog.mjs create mode 100644 tools/release-catalog.test.mjs diff --git a/README.md b/README.md index b695e28..d99ee9a 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,19 @@ # Trove Curios -第一方 Curio 与官方软件源仓库。目前包含一套用于验证 Trove 底座的测试插件,尚无正式发布的产品插件。测试源与 `source/index.json` 官方源分开,打包不会修改官方源。 +让桌面,多一点顺手。 -## 底座测试套件 +Trove 官方插件库。发现喜欢的 Curio,装进你的工作台,让常用能力触手可及。 -15 个插件工程(14 个插件 ID,其中升级插件有两个版本),覆盖 UI、Host 服务、Rust 原生后端、跨插件服务、可选/必需依赖、版本约束、生命周期、故障恢复和升级回滚。另有 22 个故意无效的安装包和 4 个异常软件源。 +## 小工具,大用途 -```sh -pnpm install --frozen-lockfile +每个 Curio 专注一件事,用轻巧的独立工具承接日常操作,把更多空间留给手头的工作。 -# 已安装 trove 命令时可省略此变量;也可指向本机 app 仓库构建产物。 -export TROVE_BIN=/absolute/path/to/app/target/debug/trove -pnpm run pack +## 按你的习惯组合 -# 可选:使用真实底座组件在临时目录自动验证。 -export TROVE_APP_DIR=/absolute/path/to/app -pnpm run verify -``` +按需安装,自由搭配。工具各自更新,也能通过服务相互配合,让你的工作方式逐渐长成自己的样子。 -打包需要 macOS、Rust/Cargo、Node.js 22+、pnpm、Python 3 和 Trove CLI。原生包只面向当前构建机器的架构;安装后的插件无需 Node.js、Python 或 Cargo。SDK 使用仓库内的版本化 `@trove/plugin-sdk@0.1.0` 包,离线复制到 UI 中。 +## 从桌面,开始 -生成结果: +打开 Trove,发现 Curios,为下一个灵感和下一项任务留出空间。 -- `dist/packages/`:15 个可安装 `.curio` 包。 -- `dist/index.json`:常规测试源,8 个插件 ID / 9 个版本包。 -- `dist/faults-index.json`:6 个故障/异常依赖插件,按需安装。 -- `dist/invalid/`:22 个无效包、4 个异常源及预期错误码。 -- `dist/verification.json`:自动验证的逐项结果、环境和观察项。 - -在 Trove 添加**本地软件源**,选择 `dist/index.json` 的绝对路径,即可安装常规测试插件。若要测试 HTTP 下载: - -```sh -pnpm run serve -# 在底座添加 HTTP 软件源:http://127.0.0.1:8765/index.json -# 用 QA_HTTP_PORT 环境变量更换端口,Ctrl-C 停止。 -``` - -完整步骤、覆盖矩阵与当前限制见 [TESTING.md](TESTING.md)。 - -## 目录 - -- `curios//`:各插件独立 Manifest 与公共服务契约。 -- `shared/ui/`、`shared/backend/`:测试面板和原生后端源码;构建时复制到各插件。 -- `source/index.json`:官方源的初始空索引。 -- `tools/`:构建、打包、测试源和真实底座验证适配器。 -- `vendor/`:版本化 SDK 分发包与来源说明。 - -项目依赖使用 pnpm;插件通过版本化的 `@trove/plugin-sdk` 引用 SDK,不通过 `../app` 引用源文件。仅自动验证适配器通过显式的 `TROVE_APP_DIR` 编译待测底座组件,插件构建与运行不依赖该路径。App 仓库保留协议规范和教学模板。 - -## 发布约定 - -1. 各插件独立声明版本、平台、依赖与权限。 -2. 构建并执行 trove curio check / pack。macOS 原生后端使用 Mac Runner 构建。 -3. 先上传不可变的版本包,再更新索引中的下载 URL、大小和 SHA-256。 -4. 最后原子发布 index.json。私有 Git 仓库不等于私有下载源;下载地址应符合分发对象的访问权限。 - -官网:https://trove.render.ink +[探索 Trove](https://trove.render.ink) · [发现 Curios](https://trove.render.ink/curios) · [开发者文档](docs/development.md) diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..54b0cc7 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,60 @@ +# Curio 开发与验收 + +此仓库承载 Trove 正式插件与生产软件源。当前包含用于首批验收的简单插件;生产发布以 `source/index.json` 为准,本地测试材料位于 `dist/`。 + +## 底座测试套件 + +15 个插件工程(14 个插件 ID,其中升级插件有两个版本),覆盖 UI、Host 服务、Rust 原生后端、跨插件服务、可选/必需依赖、版本约束、生命周期、故障恢复和升级回滚。另有 22 个故意无效的安装包和 4 个异常软件源。 + +```sh +pnpm install --frozen-lockfile + +# 已安装 trove 命令时可省略此变量;也可指向本机 app 仓库构建产物。 +export TROVE_BIN=/absolute/path/to/app/target/debug/trove +pnpm run pack + +# 可选:使用真实底座组件在临时目录自动验证。 +export TROVE_APP_DIR=/absolute/path/to/app +pnpm run verify +``` + +打包需要 macOS、Rust/Cargo、Node.js 22+、pnpm、Python 3 和 Trove CLI。原生包只面向当前构建机器的架构;安装后的插件无需 Node.js、Python 或 Cargo。SDK 使用仓库内的版本化 `@trove/plugin-sdk@0.1.0` 包,离线复制到 UI 中。 + +生成结果: + +- `dist/packages/`:15 个可安装 `.curio` 包。 +- `dist/index.json`:常规测试源,8 个插件 ID / 9 个版本包。 +- `dist/faults-index.json`:6 个故障/异常依赖插件,按需安装。 +- `dist/invalid/`:22 个无效包、4 个异常源及预期错误码。 +- `dist/verification.json`:自动验证的逐项结果、环境和观察项。 + +在 Trove 添加**本地软件源**,选择 `dist/index.json` 的绝对路径,即可安装常规测试插件。若要测试 HTTP 下载: + +```sh +pnpm run serve +# 在底座添加 HTTP 软件源:http://127.0.0.1:8765/index.json +# 用 QA_HTTP_PORT 环境变量更换端口,Ctrl-C 停止。 +``` + +完整步骤、覆盖矩阵与当前限制见 [测试指南](testing.md)。 + +插件各自通过 `curio.json.version` 管理版本;公开服务契约版本与底座兼容范围分别维护。历史发布索引只追加版本、禁止覆盖同版本产物,详见 [版本与发布](versioning.md)。`pnpm run release:prepare --base-url ` 可准备保留历史的发布材料,`pnpm run test:release` 验证这些约束。 + +## 目录 + +- `curios//`:各插件独立 Manifest 与公共服务契约。 +- `shared/ui/`、`shared/backend/`:测试面板和原生后端源码;构建时复制到各插件。 +- `source/index.json`:生产软件源与历史版本索引。 +- `tools/`:构建、打包、测试源和真实底座验证适配器。 +- `vendor/`:版本化 SDK 分发包与来源说明。 + +项目依赖使用 pnpm;插件通过版本化的 `@trove/plugin-sdk` 引用 SDK,不通过 `../app` 引用源文件。仅自动验证适配器通过显式的 `TROVE_APP_DIR` 编译待测底座组件,插件构建与运行不依赖该路径。App 仓库保留协议规范和教学模板。 + +## 发布约定 + +1. 各插件独立声明版本、平台、依赖与权限。 +2. 构建并执行 trove curio check / pack。macOS 原生后端使用 Mac Runner 构建。 +3. 先上传不可变的版本包,再更新索引中的下载 URL、大小和 SHA-256。 +4. 最后原子发布 index.json。私有 Git 仓库不等于私有下载源;下载地址应符合分发对象的访问权限。 + +官网:https://trove.render.ink diff --git a/TESTING.md b/docs/testing.md similarity index 89% rename from TESTING.md rename to docs/testing.md index ea14da2..1a7409b 100644 --- a/TESTING.md +++ b/docs/testing.md @@ -4,7 +4,7 @@ ## 快速验收 -1. 按 README 执行 `pnpm install --frozen-lockfile`、`pnpm run pack`。 +1. 按[开发文档](development.md)执行 `pnpm install --frozen-lockfile`、`pnpm run pack`。 2. 在底座添加本地源 `dist/index.json`,先安装「01 · Host 能力检查」「02 · 数据隔离对照」「03 · 原生后端检查」。逐一打开,点击「运行常规检查」。 3. 安装「04 · 公共服务提供方」,再安装并打开「05 · 跨插件调用」。提供方没有 UI;调用时才启动。 4. 安装「06 · 可选依赖缺失」,检查缺失服务报错的同时 Host 仍然可调用。 @@ -97,6 +97,20 @@ pnpm run verify 首次编译可能需要较长时间。自动结果不等于桌面 WebView/系统交互的完整验收;真实 WebView 注入、窗口视觉、系统权限、快捷键触发、通知展示、app-start 的应用启动调度仍应按上文手动检查。 +## 远程软件源验收 + +发布完成后,必须使用底座实际访问的索引 URL 单独验收: + +```sh +TROVE_APP_DIR=/absolute/path/to/app pnpm run verify \ + --remote-source https://downloads.example/source/index.json \ + --source-id trove-official +``` + +此模式不读取本地 `.curio`:在新的临时目录中通过真实 PackageManager 从远程刷新索引、下载并安装其中的每个版本,检查 UI 入口,启动安装后的原生后端,再执行 consumer → provider 调用。结果写入 `dist/remote-verification.json`;失败时非零退出,不能视为安装通过。 + +当前运行探针适用于生产源中的首批验收插件;后续新增产品插件时,应为它们补充对应的运行验收。故障源包含故意无法启动的后端,不能以全部启动成功作为标准。示例域名须替换为真实分发地址;直接读取私有 Gitea 仓库需要登录,推送源码本身不代表已发布软件源。下载可见性和历史发布管理见 [版本与发布](versioning.md)。 + ## 当前能力边界 - 当前底座默认未调用 `strict_contracts(true)`。公共服务的无效返回值可能直接透传,因此 consumer 面板的「公共服务返回值契约」会报告失败;这是能力探针,不能将其误判成自动通过。验证脚本把默认策略列为观察项,严格模式断言单独标注,不修改底座源码。 diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 0000000..9d470f0 --- /dev/null +++ b/docs/versioning.md @@ -0,0 +1,68 @@ +# 插件版本与发布记录 + +仓库是 monorepo:**每个 Curio 独立版本、独立兼容范围、独立更新**。不要求一次提交中的所有插件一起升级。 + +| 信息 | 唯一来源 | 作用 | +| --- | --- | --- | +| 插件版本 | `curios//curio.json` 的 `version` | 底座安装、更新、切换和回滚的版本 | +| 公共服务版本 | `services.provides[].version`,与 Contract 的 `version` 一致 | 调用方依赖约束;不兼容接口升级主版本 | +| 底座兼容范围 | `engine.trove` | 声明可运行的 Trove 版本;不是插件版本 | +| 已发布版本索引 | `source/index.json` | 保存全部历史版本、平台、URL、大小和 SHA-256 | +| 源码快照 | Git commit;可打 `/v` tag | 从一个已发布插件版本定位对应源代码 | +| 仓库工具版本 | 根 `package.json.version` | 构建/验证工具版本,不用于判断插件是否有更新 | + +例如只修复 `dev.trove.qa.host` 的 UI,应将该插件从 `1.0.0` 升到 `1.0.1`,其他插件保持原版本。服务接口没有变化时,服务版本仍可保持 `1.0.0`。 + +## 不可变发布 + +一个产物由 **插件 ID + 插件版本 + 平台架构** 唯一标识: + +```text +packages/dev.trove.qa.host/1.0.0/any.curio +packages/dev.trove.qa.self/1.0.0/aarch64-apple-darwin.curio +packages/dev.trove.qa.self/1.0.0/x86_64-apple-darwin.curio +``` + +同一版本可以补齐另一种架构,但不能替换已经发布的同架构内容。发布后发现问题要发新版本,不能重传覆盖旧文件。回滚切换到已有历史版本,不删除或重写坏版本对应的源码记录。服务有破坏性变更时还需要修改服务主版本和使用方依赖。 + +当前 `test-upgrade-v1` 与 `test-upgrade-v2` 是验收专用的双版本样本,共用 `dev.trove.qa.upgrade` ID。普通产品插件应只维护一份当前源码;历史通过 Git 和发布目录保存,不为每次升级复制一个源码文件夹。 + +## 准备发布 + +先修改需要发布的 Manifest,执行 `pnpm run pack` 和验证。`dist/index.json` 是可反复生成的本地开发索引;不能把它当作历史发布记录。 + +```sh +# 必须是确定的、可由底座访问的 HTTPS 分发目录;示例域名须替换。 +pnpm run release:prepare --base-url https://downloads.example/source/ +pnpm run test:release +``` + +命令会将本次打包的常规插件合并到生产源 `source/index.json`(源 ID 为 `trove-official`),并在 `dist/release/` 准备同目录结构的上传材料。故障注入插件和无效包不会进入生产源。未确定分发地址时不预先填入不可访问的下载地址。 + +校验规则: + +- 从当前包计算大小和 SHA-256,与打包元数据比较,防止索引指向错误内容。 +- 保留旧版本以及其他架构;不会因为本次只发布部分内容而重建/清空历史。 +- 同 ID、版本、架构的 SHA-256、大小、URL 必须与历史完全一致;不一致会要求升级插件版本。 +- 同一已发布版本的 `engine.trove` 不得悄悄变化。 +- 重复准备相同材料不会改变首次发布日期。 +- 先完整校验本次所有条目,再复制产物和更新本地索引。 + +`--catalog <目录>` 可在隔离目录试运行。正式发布应将 `source/` 的历史索引提交回仓库。该脚本只准备本地材料,不表示上传或远程安装已经成功。 + +## 发布顺序 + +1. 提交并推送源代码,记录 Git commit;需要时为各插件打独立 tag。 +2. 构建和验证,准备发布索引;审阅本次新增的版本与产物。 +3. 上传新安装包,拒绝覆盖已存在且内容不同的文件。 +4. 从底座实际使用的网络路径下载每个新增包,核对 SHA-256 和大小。 +5. 最后原子替换远程索引,提交并推送仓库中的发布索引。 +6. 从该远程源刷新、安装并运行验证;不能用本地文件安装冒充远程源验收。 + +发布工作串行进行;两种架构先各自构建,再将产物合并至同一版本。若更新 `shared/ui/` 或 `shared/backend/`,所有受影响且需要重新发布的插件都要增加插件版本,未受影响的插件不必修改。 + +## 私有源码与下载认证 + +当前 `https://git.render.ink/trove/curios` 是私有源码仓库,匿名访问原始 JSON 会跳转到登录页。推送源代码本身不能使它成为可安装的软件源。 + +Trove 当前 HTTP 软件源没有仓库登录凭据配置。生产分发可使用免登录的安装包/索引下载入口,或在底座支持私有软件源认证后使用受保护的入口;不要把访问令牌写进 Manifest、源码索引或下载 URL。源码仓库可保持私有,安装包的可见性单独决定。 diff --git a/package.json b/package.json index df1a4a6..e3b45a8 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "trove-curio-test-lab", + "name": "trove-curios", "version": "0.1.0", "private": true, "type": "module", @@ -9,6 +9,8 @@ "pack": "node tools/pack.mjs", "verify": "node tools/verify.mjs", "serve": "node tools/serve.mjs", + "release:prepare": "node tools/prepare-release.mjs", + "test:release": "node --test tools/release-catalog.test.mjs", "test": "node tools/verify.mjs" }, "dependencies": { diff --git a/tools/host-check.rs b/tools/host-check.rs index 74fa8dc..c5f3d76 100644 --- a/tools/host-check.rs +++ b/tools/host-check.rs @@ -372,6 +372,14 @@ fn register( #[tokio::main] async fn main() -> Result<()> { + let args: Vec = std::env::args().collect(); + if args.get(1).map(String::as_str) == Some("--remote-source") { + return remote_source_check( + args.get(2).context("remote source URL required")?, + args.get(3).context("remote source ID required")?, + ) + .await; + } let dist = PathBuf::from(std::env::args().nth(1).context("dist directory required")?) .canonicalize()?; let sandbox = tempfile::Builder::new() @@ -468,3 +476,133 @@ async fn main() -> Result<()> { gateway.shutdown().await; Ok(()) } + +async fn remote_source_check(url: &str, source_id: &str) -> Result<()> { + let sandbox = tempfile::Builder::new() + .prefix("trove-remote-qa-") + .tempdir_in("/tmp")?; + let paths = AppPaths::under(sandbox.path()); + let packages = Arc::new(PackageManager::open(paths.clone(), "0.1.0", target())?); + let runtime = BackendSupervisor::new(sandbox.path().join("host.sock")); + let bus = ServiceBus::new(runtime.clone()); + bus.register_builtin(Arc::new(RuntimeService { + paths: paths.clone(), + packages: packages.clone(), + }))?; + bus.register_builtin(Arc::new(Arc::new(Storage::new(paths.support.join("data")))))?; + let gateway = + UnixGateway::bind(runtime.socket_path().into(), runtime.clone(), bus.clone()).await?; + let mut rows = Vec::new(); + check(&mut rows, "从真实远程源读取索引", async { + packages.add_source(SourceConfig { + id: source_id.into(), + name: source_id.into(), + kind: SourceKind::Http, + index: url.into(), + enabled: true, + priority: 0, + refresh_interval_seconds: 3600, + })?; + let refreshed = packages.refresh_source(source_id).await?; + ensure!(!refreshed.stale, "Remote source refresh used stale cache"); + ensure!( + !packages.catalog().is_empty(), + "Remote source has no installable plugins" + ); + Ok(json!({"url":url,"sourceId":source_id,"curioCount":packages.catalog().len()})) + }) + .await; + if rows.iter().all(|row| row["status"] == "pass") { + for entry in packages.catalog() { + for version in entry.package.versions { + check(&mut rows, format!("远程安装 {}@{}", entry.package.id, version.version), async { + let package = packages.install_from_source(source_id, &entry.package.id, &version.version, true).await?; + ensure!(package.source_id.as_deref() == Some(source_id), "Installation wasn't bound to the remote source"); + if let Some(ui) = &package.manifest.ui { + let asset = trove::runtime::assets::resolve_asset(&package.root, &package.manifest.id, &ui.entry, &format!("/{}/{}", package.manifest.id, ui.entry))?; + ensure!(asset.is_file(), "Installed UI entry is missing"); + } + Ok(json!({"id":package.manifest.id,"version":package.active_version,"sourceId":package.source_id})) + }).await; + } + } + for package in packages.list() { + register(&package, &paths, &runtime, &bus)?; + } + for package in packages + .list() + .into_iter() + .filter(|package| package.manifest.backend.is_some()) + { + check( + &mut rows, + format!("运行远程安装后端 {}", package.manifest.id), + async { + let context = trove::service::CallContext::new( + Caller { + curio_id: Some(package.manifest.id.clone()), + instance_id: None, + endpoint: "ui".into(), + }, + "remote-check", + ); + let result = bus + .call(context, "@self", "info", json!({}), Default::default()) + .await?; + ensure!( + result["curioId"] == package.manifest.id + && result["version"] == package.active_version, + "Backend identity doesn't match installed remote version" + ); + ensure!( + result["pid"].as_u64().unwrap_or(0) > 0, + "Backend didn't report a process" + ); + Ok(result) + }, + ) + .await; + } + if packages.get("dev.trove.qa.consumer").is_ok() + && packages.get("dev.trove.qa.provider").is_ok() + { + check(&mut rows, "远程安装插件间的服务调用", async { + let context = trove::service::CallContext::new( + Caller { + curio_id: Some("dev.trove.qa.consumer".into()), + instance_id: None, + endpoint: "ui".into(), + }, + "remote-consumer", + ); + let result = bus + .call( + context, + "dev.trove.qa.provider.probe", + "echo", + json!({"remote":true,"text":"远程安装 🧪"}), + Default::default(), + ) + .await?; + ensure!( + result == json!({"remote":true,"text":"远程安装 🧪"}), + "Cross-plugin response changed" + ); + Ok(result) + }) + .await; + } + } + bus.shutdown().await; + runtime.shutdown_all().await; + gateway.shutdown().await; + sandbox.close()?; + let failed = rows.iter().filter(|row| row["status"] == "fail").count(); + emit(json!({ + "schemaVersion":1,"sourceUrl":url,"sourceId":source_id,"target":target(), + "scope":"Remote HTTP index and packages via real PackageManager; installed native backends via real UDS. Desktop UI rendering is not included.", + "passed":rows.len()-failed,"failed":failed,"temporaryDataRemoved":true,"results":rows, + })); + ensure!(failed == 0, "Remote source verification failed"); + Ok(()) +} diff --git a/tools/prepare-release.mjs b/tools/prepare-release.mjs new file mode 100644 index 0000000..d4e92c2 --- /dev/null +++ b/tools/prepare-release.mjs @@ -0,0 +1,69 @@ +import { readFile, writeFile, mkdir, copyFile, rename, rm } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { parseArgs } from 'node:util'; +import path from 'node:path'; +import { root, output } from './suite.mjs'; +import { appendRelease, artifactPath } from './release-catalog.mjs'; + +const { values } = parseArgs({ options: { + 'base-url': { type: 'string' }, + catalog: { type: 'string', default: 'source' }, +} }); +if (!values['base-url']) throw new Error('Usage: pnpm run release:prepare --base-url https://downloads.example/source/'); +const base = new URL(values['base-url']); +if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash) { + throw new Error('Publication base must be an HTTPS directory URL without credentials, query or fragment'); +} +if (!base.pathname.endsWith('/')) base.pathname += '/'; +const catalogDirectory = path.resolve(root, values.catalog); +const publishDirectory = path.join(output, 'release'); +const timestamp = new Date().toISOString(); +const specifications = [ + { file: 'index.json', id: 'trove-official', name: 'Trove 官方软件源' }, +]; +const catalogs = new Map(); +for (const spec of specifications) { + let index; + try { index = JSON.parse(await readFile(path.join(catalogDirectory, spec.file), 'utf8')); } + catch (error) { + if (error.code !== 'ENOENT') throw error; + index = { schemaVersion: 1, id: spec.id, name: spec.name, generatedAt: timestamp, curios: [] }; + } + if (index.id !== spec.id || index.schemaVersion !== 1 || !Array.isArray(index.curios)) throw new Error(`Unexpected catalog identity: ${spec.file}`); + catalogs.set(spec.file, index); +} +const entries = JSON.parse(await readFile(path.join(output, 'suite.json'), 'utf8')); +const files = []; +// Validate every proposed addition before changing any catalog or copying artifacts. +for (const entry of entries) { + if (entry.fault) continue; + const bytes = await readFile(entry.file); + const sha256 = createHash('sha256').update(bytes).digest('hex'); + if (sha256 !== entry.artifact.sha256 || bytes.length !== entry.artifact.size) throw new Error(`Build metadata doesn't match ${entry.file}; run pack again`); + const relative = artifactPath(entry.manifest, entry.artifact.target); + const url = new URL(relative.split('/').map(encodeURIComponent).join('/'), base).href; + const spec = specifications[0]; + const artifact = { ...entry.artifact, url }; + catalogs.set(spec.file, appendRelease(catalogs.get(spec.file), entry.manifest, artifact, timestamp)); + files.push({ from: entry.file, relative, sha256, size: bytes.length, url }); +} +// Only the current approved publication plan belongs in the generated upload directory. +await rm(publishDirectory, { recursive: true, force: true }); +for (const file of files) { + const destination = path.join(publishDirectory, file.relative); + await mkdir(path.dirname(destination), { recursive: true }); + await copyFile(file.from, destination); +} +await mkdir(catalogDirectory, { recursive: true }); +await mkdir(publishDirectory, { recursive: true }); +for (const [file, index] of catalogs) { + const text = JSON.stringify(index, null, 2) + '\n'; + await writeFile(path.join(publishDirectory, file), text); + const temporary = path.join(catalogDirectory, `${file}.tmp`); + await writeFile(temporary, text); + await rename(temporary, path.join(catalogDirectory, file)); +} +await writeFile(path.join(publishDirectory, 'upload-plan.json'), JSON.stringify({ baseUrl: base.href, files }, null, 2) + '\n'); +console.log(`Prepared ${files.length} immutable artifacts in ${publishDirectory}`); +console.log(`Catalog history: ${catalogDirectory}`); +console.log('Next: upload package files without overwrite, verify remote bytes, then atomically publish index.json.'); diff --git a/tools/release-catalog.mjs b/tools/release-catalog.mjs new file mode 100644 index 0000000..4c2317a --- /dev/null +++ b/tools/release-catalog.mjs @@ -0,0 +1,37 @@ +// Published identity is (Curio ID, version, target). Existing identities are immutable. +export function appendRelease(catalog, manifest, artifact, releasedAt) { + const next = structuredClone(catalog); + let curio = next.curios.find(item => item.id === manifest.id); + if (!curio) { + curio = { id: manifest.id, name: manifest.name, description: manifest.description ?? '', versions: [] }; + next.curios.push(curio); + } + let version = curio.versions.find(item => item.version === manifest.version); + if (version && version.engine.trove !== manifest.engine.trove) { + throw new Error(`${manifest.id}@${manifest.version}: published engine range cannot change; bump the plugin version`); + } + const existing = version?.artifacts.find(item => item.target === artifact.target); + if (existing) { + for (const key of ['sha256', 'size', 'url']) { + if (existing[key] !== artifact[key]) throw new Error(`${manifest.id}@${manifest.version}/${artifact.target}: published ${key} differs; bump the plugin version`); + } + return next; + } + if (!version) { + version = { version: manifest.version, engine: structuredClone(manifest.engine), releasedAt, artifacts: [] }; + curio.versions.unshift(version); + curio.name = manifest.name; + curio.description = manifest.description ?? ''; + } + version.artifacts.push(structuredClone(artifact)); + next.generatedAt = releasedAt; + return next; +} + +export function artifactPath(manifest, target) { + // Validate path components before they become a publication path. + if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(manifest.id)) throw new Error('Invalid Curio ID'); + if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(manifest.version)) throw new Error('Invalid plugin version'); + if (!['any', 'aarch64-apple-darwin', 'x86_64-apple-darwin'].includes(target)) throw new Error('Unsupported artifact target'); + return `packages/${manifest.id}/${manifest.version}/${target}.curio`; +} diff --git a/tools/release-catalog.test.mjs b/tools/release-catalog.test.mjs new file mode 100644 index 0000000..1428ba7 --- /dev/null +++ b/tools/release-catalog.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { appendRelease, artifactPath } from './release-catalog.mjs'; + +const initial = { schemaVersion: 1, id: 'trove-qa', name: 'QA', generatedAt: '2026-09-01T00:00:00Z', curios: [] }; +const manifest = { id: 'dev.trove.qa.example', name: 'Example', version: '1.0.0', engine: { trove: '^0.1.0' } }; +const arm = { target: 'aarch64-apple-darwin', url: 'https://example.org/1.0.0/arm.curio', sha256: 'a'.repeat(64), size: 100 }; +const timestamp = '2026-09-09T00:00:00Z'; +const released = () => appendRelease(initial, manifest, arm, timestamp); + +test('publishing a new version retains all earlier releases', () => { + const original = released(); + const updated = appendRelease(original, { ...manifest, version: '1.1.0' }, { ...arm, url: 'https://example.org/1.1.0/arm.curio', sha256: 'b'.repeat(64) }, '2026-09-10T00:00:00Z'); + assert.equal(updated.curios[0].versions.length, 2); + assert.deepEqual(updated.curios[0].versions[1], original.curios[0].versions[0]); + assert.equal(initial.curios.length, 0); +}); +test('repeating an identical release does not change dates or metadata', () => { + const original = released(); + assert.deepEqual(appendRelease(original, manifest, arm, '2026-10-01T00:00:00Z'), original); +}); +test('one version can add a second architecture without replacing the first', () => { + const original = released(); + const updated = appendRelease(original, manifest, { ...arm, target: 'x86_64-apple-darwin', url: 'https://example.org/1.0.0/intel.curio', sha256: 'c'.repeat(64) }, '2026-09-10T00:00:00Z'); + assert.equal(updated.curios[0].versions.length, 1); + assert.equal(updated.curios[0].versions[0].artifacts.length, 2); + assert.deepEqual(updated.curios[0].versions[0].artifacts[0], arm); + assert.equal(updated.curios[0].versions[0].releasedAt, timestamp); +}); +for (const [key, value] of [['sha256', 'd'.repeat(64)], ['size', 101], ['url', 'https://example.org/replaced.curio']]) { + test(`same version and architecture cannot replace ${key}`, () => { + const original = released(); + assert.throws(() => appendRelease(original, manifest, { ...arm, [key]: value }, timestamp), /bump the plugin version/); + assert.deepEqual(original.curios[0].versions[0].artifacts[0], arm); + }); +} +test('published engine compatibility cannot change silently', () => { + assert.throws(() => appendRelease(released(), { ...manifest, engine: { trove: '^0.2.0' } }, arm, timestamp), /engine range cannot change/); +}); +test('artifact paths distinguish architecture, ID and version and reject traversal', () => { + assert.equal(artifactPath(manifest, 'any'), 'packages/dev.trove.qa.example/1.0.0/any.curio'); + assert.notEqual(artifactPath(manifest, 'aarch64-apple-darwin'), artifactPath(manifest, 'x86_64-apple-darwin')); + assert.throws(() => artifactPath({ ...manifest, id: '../outside' }, 'any')); + assert.throws(() => artifactPath({ ...manifest, version: '../outside' }, 'any')); + assert.throws(() => artifactPath(manifest, '../outside')); +}); diff --git a/tools/verify.mjs b/tools/verify.mjs index 60e3ce4..54f7104 100644 --- a/tools/verify.mjs +++ b/tools/verify.mjs @@ -1,4 +1,5 @@ -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; +import { parseArgs } from 'node:util'; import { createInterface } from 'node:readline'; import { mkdir, writeFile, access, rm } from 'node:fs/promises'; import path from 'node:path'; @@ -8,10 +9,12 @@ import { assert, equal, expectError, hostChecks, backendChecks, sleep } from '.. import { serve } from './serve.mjs'; // Only this optional integration adapter depends on the app checkout. -await rm(path.join(output, 'verification.json'), { force: true }); +const { values: args } = parseArgs({ options: { 'remote-source': { type: 'string' }, 'source-id': { type: 'string', default: 'trove-official' } } }); +const reportPath = path.join(output, args['remote-source'] ? 'remote-verification.json' : 'verification.json'); +await rm(reportPath, { force: true }); const app = process.env.TROVE_APP_DIR; if (!app) throw new Error('Set TROVE_APP_DIR to the app repository, then run pnpm run verify.'); -await access(path.join(output, 'suite.json')).catch(() => { throw new Error('Run pnpm run pack first.'); }); +if (!args['remote-source']) await access(path.join(output, 'suite.json')).catch(() => { throw new Error('Run pnpm run pack first.'); }); const harness = path.join(output, 'host-check'); await mkdir(harness, { recursive: true }); const manifest = `[package] @@ -37,9 +40,18 @@ await writeFile(path.join(harness, 'Cargo.toml'), manifest); const target = process.env.TROVE_QA_TARGET_DIR || path.join(output, 'host-target'); try { run('cargo', ['build', '--manifest-path', path.join(harness, 'Cargo.toml'), '--target-dir', target]); + if (args['remote-source']) { + const result = spawnSync(path.join(target, 'debug/trove-qa-host-check'), ['--remote-source', args['remote-source'], args['source-id']], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], timeout: 180000 }); + if (result.error) throw result.error; + const report = JSON.parse(result.stdout); + report.generatedAt = new Date().toISOString(); + await writeFile(reportPath, JSON.stringify(report, null, 2) + '\n'); + console.log(`${report.passed} passed, ${report.failed} failed. Report: ${reportPath}`); + process.exit(result.status ?? 1); + } run(troveBin, ['protocol', 'test', '--command', path.join(root, 'shared/backend/target/release/trove-qa-backend')]); } catch (error) { - await writeFile(path.join(output, 'verification.json'), JSON.stringify({ + await writeFile(reportPath, JSON.stringify({ schemaVersion: 1, generatedAt: new Date().toISOString(), passed: 0, failed: 1, observed: 0, results: [{ name: '编译适配器 / CLI 协议检查', status: 'fail', error: { message: error.message } }], }, null, 2) + '\n');