feat: publish Curios directly through the Git source

This commit is contained in:
2026-09-09 20:17:55 +08:00
parent 68b7164df8
commit 72272eeb1a
17 changed files with 262 additions and 35 deletions
+1
View File
@@ -5,5 +5,6 @@ target/
.env
.env.*
*.curio
!/source/packages/**/*.curio
.DS_Store
/curios/*/backend/
+3 -3
View File
@@ -38,7 +38,7 @@ pnpm run serve
完整步骤、覆盖矩阵与当前限制见 [测试指南](testing.md)。
插件各自通过 `curio.json.version` 管理版本;公开服务契约版本与底座兼容范围分别维护。历史发布索引只追加版本、禁止覆盖同版本产物,详见 [版本与发布](versioning.md)。`pnpm run release:prepare --base-url <HTTPS 分发目录>` 可准备保留历史的发布材料,`pnpm run test:release` 验证这些约束。
插件各自通过 `curio.json.version` 管理版本;公开服务契约版本与底座兼容范围分别维护。历史发布索引只追加版本、禁止覆盖同版本产物,详见 [版本与发布](versioning.md)。`pnpm run release:prepare` 将安装包与索引准备到 `source/`,提交并推送后即可通过 Git 仓库源安装。`pnpm run test:release` 验证版本约束。
## 目录
@@ -54,7 +54,7 @@ pnpm run serve
1. 各插件独立声明版本、平台、依赖与权限。
2. 构建并执行 trove curio check / pack。macOS 原生后端使用 Mac Runner 构建。
3. 先上传不可变的版本包,再更新索引中的下载 URL、大小和 SHA-256。
4. 最后原子发布 index.json。私有 Git 仓库不等于私有下载源;下载地址应符合分发对象的访问权限。
3. 执行 `pnpm run release:prepare`,检查 `source/index.json` 的新增版本与 `source/packages/` 中的不可变版本包。
4. 将索引和安装包在同一提交中推送,再使用 Git 仓库地址完成远程安装与运行验证。私有仓库沿用本机 Git 认证。
官网:https://trove.render.ink
+5 -4
View File
@@ -99,17 +99,18 @@ pnpm run verify
## 远程软件源验收
发布完成后,必须使用底座实际访问的索引 URL 单独验收:
发布完成后,必须使用底座实际访问的 Git 仓库单独验收:
```sh
TROVE_APP_DIR=/absolute/path/to/app pnpm run verify \
--remote-source https://downloads.example/source/index.json \
--remote-source git@git.render.ink:trove/curios.git \
--source-kind git \
--source-id trove-official
```
此模式不读取本地 `.curio`:在新的临时目录中通过真实 PackageManager 从远程刷新索引、下载并安装其中的每个版本,检查 UI 入口,启动安装后的原生后端,再执行 consumer → provider 调用。结果写入 `dist/remote-verification.json`;失败时非零退出,不能视为安装通过。
此模式不读取工作区的 `.curio`:在新的临时目录中通过真实 PackageManager 获取远程仓库、读取索引、安装该提交中的每个版本,检查 UI 入口,启动安装后的原生后端,再执行 consumer → provider 调用。结果写入 `dist/remote-verification.json`;失败时非零退出,不能视为安装通过。独立 HTTP 源可使用 `--source-kind http` 并传入索引 URL。
当前运行探针适用于生产源中的首批验收插件;后续新增产品插件时,应为它们补充对应的运行验收。故障源包含故意无法启动的后端,不能以全部启动成功作为标准。示例域名须替换为真实分发地址;直接读取私有 Gitea 仓库需要登录,推送源码本身不代表已发布软件源。下载可见性和历史发布管理见 [版本与发布](versioning.md)。
当前运行探针适用于生产源中的首批验收插件;后续新增产品插件时,应为它们补充对应的运行验收。故障源包含故意无法启动的后端,不能以全部启动成功作为标准。仓库结构、认证和历史发布管理见 [版本与发布](versioning.md)。
## 当前能力边界
+18 -13
View File
@@ -32,12 +32,11 @@ packages/dev.trove.qa.self/1.0.0/x86_64-apple-darwin.curio
先修改需要发布的 Manifest,执行 `pnpm run pack` 和验证。`dist/index.json` 是可反复生成的本地开发索引;不能把它当作历史发布记录。
```sh
# 必须是确定的、可由底座访问的 HTTPS 分发目录;示例域名须替换。
pnpm run release:prepare --base-url https://downloads.example/source/
pnpm run release:prepare
pnpm run test:release
```
命令会将本次打包的常规插件合并到生产源 `source/index.json`(源 ID 为 `trove-official`),并在 `dist/release/` 准备同目录结构的上传材料。故障注入插件和无效包不会进入生产源。未确定分发地址时不预先填入不可访问的下载地址。
命令会将本次打包的常规插件合并到生产源 `source/index.json`(源 ID 为 `trove-official`),并把不可变安装包复制到 `source/packages/`。索引中的包地址相对于 `source/`,索引与包在同一 Git 提交中发布。`dist/release/` 保留本次发布材料副本。故障注入插件和无效包不会进入生产源。
校验规则:
@@ -48,21 +47,27 @@ pnpm run test:release
- 重复准备相同材料不会改变首次发布日期。
- 先完整校验本次所有条目,再复制产物和更新本地索引。
`--catalog <目录>` 可在隔离目录试运行。正式发布应将 `source/` 的历史索引提交回仓库。该脚本只准备本地材料,不表示上传或远程安装已经成功。
`--catalog <目录>` 可在隔离目录试运行。正式发布应将 `source/` 的历史索引与安装包提交回仓库。该脚本只准备本地材料,不表示推送或远程安装已经成功。
需要独立 HTTP 分发时,可显式传入 `--base-url <HTTPS 分发目录>` 生成绝对下载地址,并使用 `dist/release/` 上传;已有版本的 URL 仍不能覆盖。
## 发布顺序
1. 提交并推送源代码,记录 Git commit;需要时为各插件打独立 tag。
2. 构建和验证,准备发布索引;审阅本次新增的版本与产物。
3. 上传新安装包,拒绝覆盖已存在且内容不同的文件。
4. 从底座实际使用的网络路径下载每个新增包,核对 SHA-256 和大小。
5. 最后原子替换远程索引,提交并推送仓库中的发布索引。
6. 从该远程源刷新、安装并运行验证;不能用本地文件安装冒充远程源验收。
1. 更新需要发布的插件版本,构建并完成验证。
2. 执行 `pnpm run release:prepare`;审阅本次新增的版本、架构、大小和 SHA-256。
3. 将源码、`source/index.json` 和 `source/packages/` 在同一提交中推送;需要时为各插件打独立 tag。
4. 从远程 Git 仓库刷新、安装并运行验证;不能用本地文件安装冒充远程源验收。
发布工作串行进行;两种架构先各自构建,再将产物合并至同一版本。若更新 `shared/ui/` 或 `shared/backend/`,所有受影响且需要重新发布的插件都要增加插件版本,未受影响的插件不必修改。
## 私有源码与下载认证
## 添加仓库源
当前 `https://git.render.ink/trove/curios` 是私有源码仓库,匿名访问原始 JSON 会跳转到登录页。推送源代码本身不能使它成为可安装的软件源。
在 Trove「软件源 → 添加软件源」中填写:
Trove 当前 HTTP 软件源没有仓库登录凭据配置。生产分发可使用免登录的安装包/索引下载入口,或在底座支持私有软件源认证后使用受保护的入口;不要把访问令牌写进 Manifest、源码索引或下载 URL。源码仓库可保持私有,安装包的可见性单独决定。
- 显示名称:Trove 官方软件源
- 唯一 ID:`trove-official`
- 源类型:Git 仓库
- 仓库地址:`git@git.render.ink:trove/curios.git`
- 优先级:`0`
也支持 HTTPS 克隆地址 `https://git.render.ink/trove/curios.git`。私有仓库沿用本机 Git 的 SSH 密钥或 HTTPS credential helper;不要把访问令牌写进 Manifest、索引或 URL。添加后刷新,底座自动读取默认分支的 `source/index.json`,并固定该提交读取仓库内的安装包。用户不需要填写原始 JSON 地址或配置额外的静态下载站点。
+194 -2
View File
@@ -2,6 +2,198 @@
"schemaVersion": 1,
"id": "trove-official",
"name": "Trove 官方软件源",
"generatedAt": "2026-09-08T07:48:26.789Z",
"curios": []
"generatedAt": "2026-09-09T12:17:24.404Z",
"curios": [
{
"id": "dev.trove.qa.host",
"name": "01 · Host 能力检查",
"description": "UI、SDK 桥接、服务发现、存储、文件和桌面交互。",
"versions": [
{
"version": "1.0.0",
"engine": {
"trove": ">=0.1.0 <1.0.0"
},
"releasedAt": "2026-09-09T12:17:24.404Z",
"artifacts": [
{
"target": "any",
"url": "packages/dev.trove.qa.host/1.0.0/any.curio",
"sha256": "15a644d1231b237092b3c4ed19ccece12e92e8f78aac681af2279150da65bad1",
"size": 20082
}
]
}
]
},
{
"id": "dev.trove.qa.isolation",
"name": "02 · 数据隔离对照",
"description": "与 Host 检查使用相同存储键,验证插件间独立命名空间。",
"versions": [
{
"version": "1.0.0",
"engine": {
"trove": ">=0.1.0 <1.0.0"
},
"releasedAt": "2026-09-09T12:17:24.404Z",
"artifacts": [
{
"target": "any",
"url": "packages/dev.trove.qa.isolation/1.0.0/any.curio",
"sha256": "8bfc084b75e91874b6c6c488c8703b2ccd5529a92f9f2cb56ab57f69c491e687",
"size": 20106
}
]
}
]
},
{
"id": "dev.trove.qa.self",
"name": "03 · 原生后端检查",
"description": "UI → @self → 原生 Rust 后端 → Host;并发、取消、事件。",
"versions": [
{
"version": "1.0.0",
"engine": {
"trove": ">=0.1.0 <1.0.0"
},
"releasedAt": "2026-09-09T12:17:24.404Z",
"artifacts": [
{
"target": "aarch64-apple-darwin",
"url": "packages/dev.trove.qa.self/1.0.0/aarch64-apple-darwin.curio",
"sha256": "328d39a3ce95d33eae8b1575c307a9aec8d3c5319e89c250b04c1389ecea5b5e",
"size": 346745
}
]
}
]
},
{
"id": "dev.trove.qa.provider",
"name": "04 · 公共服务提供方",
"description": "无 UI,按需启动;20 秒空闲后退出。",
"versions": [
{
"version": "1.0.0",
"engine": {
"trove": ">=0.1.0 <1.0.0"
},
"releasedAt": "2026-09-09T12:17:24.404Z",
"artifacts": [
{
"target": "aarch64-apple-darwin",
"url": "packages/dev.trove.qa.provider/1.0.0/aarch64-apple-darwin.curio",
"sha256": "726f0f47bf50294f9be067437995dbbb59b3b52017b95bff665273165ff2a46d",
"size": 327830
}
]
}
]
},
{
"id": "dev.trove.qa.consumer",
"name": "05 · 跨插件调用",
"description": "依赖公共服务;验证发现、契约、事件与后端回调。",
"versions": [
{
"version": "1.0.0",
"engine": {
"trove": ">=0.1.0 <1.0.0"
},
"releasedAt": "2026-09-09T12:17:24.404Z",
"artifacts": [
{
"target": "any",
"url": "packages/dev.trove.qa.consumer/1.0.0/any.curio",
"sha256": "503112dd4fc316e370c6f95fdca717f02f23ac68a02e751780a43a721dfb2bd3",
"size": 20079
}
]
}
]
},
{
"id": "dev.trove.qa.optional",
"name": "06 · 可选依赖缺失",
"description": "缺失可选服务时 UI 仍可工作,调用返回 service.not_found。",
"versions": [
{
"version": "1.0.0",
"engine": {
"trove": ">=0.1.0 <1.0.0"
},
"releasedAt": "2026-09-09T12:17:24.404Z",
"artifacts": [
{
"target": "any",
"url": "packages/dev.trove.qa.optional/1.0.0/any.curio",
"sha256": "ce01d0508b7b1610e835f87095b5f1e45ad1652c924ecb58d113efa4a3c47cc7",
"size": 20101
}
]
}
]
},
{
"id": "dev.trove.qa.autostart",
"name": "07 · 随底座启动",
"description": "无 UI 的 app-start 后端;重启底座后检查状态和 PID。",
"versions": [
{
"version": "1.0.0",
"engine": {
"trove": ">=0.1.0 <1.0.0"
},
"releasedAt": "2026-09-09T12:17:24.404Z",
"artifacts": [
{
"target": "aarch64-apple-darwin",
"url": "packages/dev.trove.qa.autostart/1.0.0/aarch64-apple-darwin.curio",
"sha256": "38605052d1c7fade9301ea4972b2668a94ab5e563058a1de63c8937b4717d5b7",
"size": 327826
}
]
}
]
},
{
"id": "dev.trove.qa.upgrade",
"name": "08 · 升级与数据保留",
"description": "1.1.0 更新;显示版本并读取基线标记。",
"versions": [
{
"version": "1.1.0",
"engine": {
"trove": ">=0.1.0 <1.0.0"
},
"releasedAt": "2026-09-09T12:17:24.404Z",
"artifacts": [
{
"target": "any",
"url": "packages/dev.trove.qa.upgrade/1.1.0/any.curio",
"sha256": "500493b4156c3f143c8bc685bad495a41e320f9d7b8346a5ea939141d037a0f2",
"size": 20033
}
]
},
{
"version": "1.0.0",
"engine": {
"trove": ">=0.1.0 <1.0.0"
},
"releasedAt": "2026-09-09T12:17:24.404Z",
"artifacts": [
{
"target": "any",
"url": "packages/dev.trove.qa.upgrade/1.0.0/any.curio",
"sha256": "834a1159d18b1d44b5aeb38cf9b60f5a1868df06cdc370d4d08026170e1bb19f",
"size": 20030
}
]
}
]
}
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10 -4
View File
@@ -377,6 +377,7 @@ async fn main() -> Result<()> {
return remote_source_check(
args.get(2).context("remote source URL required")?,
args.get(3).context("remote source ID required")?,
args.get(4).map(String::as_str).unwrap_or("http"),
)
.await;
}
@@ -477,7 +478,12 @@ async fn main() -> Result<()> {
Ok(())
}
async fn remote_source_check(url: &str, source_id: &str) -> Result<()> {
async fn remote_source_check(url: &str, source_id: &str, kind: &str) -> Result<()> {
let kind = match kind {
"git" => SourceKind::Git,
"http" => SourceKind::Http,
_ => anyhow::bail!("Remote source kind must be git or http"),
};
let sandbox = tempfile::Builder::new()
.prefix("trove-remote-qa-")
.tempdir_in("/tmp")?;
@@ -497,7 +503,7 @@ async fn remote_source_check(url: &str, source_id: &str) -> Result<()> {
packages.add_source(SourceConfig {
id: source_id.into(),
name: source_id.into(),
kind: SourceKind::Http,
kind,
index: url.into(),
enabled: true,
priority: 0,
@@ -599,8 +605,8 @@ async fn remote_source_check(url: &str, source_id: &str) -> Result<()> {
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.",
"schemaVersion":1,"sourceUrl":url,"sourceId":source_id,"sourceKind":kind,"target":target(),
"scope":"Remote source 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");
+24 -7
View File
@@ -9,12 +9,11 @@ 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) {
const base = values['base-url'] ? new URL(values['base-url']) : null;
if (base && (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 += '/';
if (base && !base.pathname.endsWith('/')) base.pathname += '/';
const catalogDirectory = path.resolve(root, values.catalog);
const publishDirectory = path.join(output, 'release');
const timestamp = new Date().toISOString();
@@ -41,18 +40,36 @@ for (const entry of entries) {
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 url = base ? new URL(relative.split('/').map(encodeURIComponent).join('/'), base).href : relative;
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 });
}
// Git publication keeps immutable packages alongside the canonical index.
if (!base) {
for (const file of files) {
try {
const existing = await readFile(path.join(catalogDirectory, file.relative));
if (existing.length !== file.size || createHash('sha256').update(existing).digest('hex') !== file.sha256) {
throw new Error(`Published artifact differs: ${file.relative}; bump the plugin version`);
}
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
}
// 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);
if (!base) {
const published = path.join(catalogDirectory, file.relative);
await mkdir(path.dirname(published), { recursive: true });
await copyFile(file.from, published);
}
}
await mkdir(catalogDirectory, { recursive: true });
await mkdir(publishDirectory, { recursive: true });
@@ -63,7 +80,7 @@ for (const [file, index] of catalogs) {
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');
await writeFile(path.join(publishDirectory, 'upload-plan.json'), JSON.stringify({ kind: base ? 'http' : 'git', 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.');
console.log(base ? 'Next: upload package files without overwrite, verify remote bytes, then atomically publish index.json.' : 'Next: commit source/index.json and source/packages/ together, push the repository, then verify the Git source.');
+7 -2
View File
@@ -9,7 +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.
const { values: args } = parseArgs({ options: { 'remote-source': { type: 'string' }, 'source-id': { type: 'string', default: 'trove-official' } } });
const { values: args } = parseArgs({ options: {
'remote-source': { type: 'string' },
'source-id': { type: 'string', default: 'trove-official' },
'source-kind': { type: 'string', default: 'http' },
} });
if (!['git', 'http'].includes(args['source-kind'])) throw new Error('--source-kind must be git or http');
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;
@@ -41,7 +46,7 @@ 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 });
const result = spawnSync(path.join(target, 'debug/trove-qa-host-check'), ['--remote-source', args['remote-source'], args['source-id'], args['source-kind']], { 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();