feat: prepare versioned production Curio releases
This commit is contained in:
@@ -372,6 +372,14 @@ fn register(
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args: Vec<String> = 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(())
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
+16
-4
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user