commit 95d5b062e14aee762b3d3f84d0530894094418f6
parent 8f992123257c251a1c0c79e0b84b3de14cdace86
Author: triesap <tyson@radroots.org>
Date: Fri, 20 Feb 2026 23:44:56 +0000
build: add deterministic export manifest and checksums
Diffstat:
5 files changed, 187 insertions(+), 22 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -5027,7 +5027,10 @@ dependencies = [
name = "xtask"
version = "0.1.0"
dependencies = [
+ "hex",
"serde",
+ "serde_json",
+ "sha2",
"toml 0.8.23",
]
diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml
@@ -7,4 +7,7 @@ license.workspace = true
[dependencies]
serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+sha2 = { workspace = true }
+hex = { workspace = true }
toml = { workspace = true }
diff --git a/crates/xtask/src/contract.rs b/crates/xtask/src/contract.rs
@@ -64,6 +64,7 @@ pub struct CompatibilityRules {
pub struct ExportMapping {
pub language: ExportLanguage,
pub packages: BTreeMap<String, String>,
+ pub artifacts: Option<ExportArtifacts>,
}
#[derive(Debug, Deserialize)]
@@ -72,6 +73,14 @@ pub struct ExportLanguage {
pub repository: String,
}
+#[derive(Debug, Deserialize, Default)]
+pub struct ExportArtifacts {
+ pub models_dir: Option<String>,
+ pub constants_dir: Option<String>,
+ pub wasm_dist_dir: Option<String>,
+ pub manifest_file: Option<String>,
+}
+
#[derive(Debug)]
pub struct ContractBundle {
pub root: PathBuf,
@@ -128,6 +137,12 @@ pub fn validate_contract_bundle(bundle: &ContractBundle) -> Result<(), String> {
if bundle.manifest.surface.model_crates.is_empty() {
return Err("contract surface.model_crates must not be empty".to_string());
}
+ if bundle.manifest.surface.algorithm_crates.is_empty() {
+ return Err("contract surface.algorithm_crates must not be empty".to_string());
+ }
+ if bundle.manifest.surface.wasm_crates.is_empty() {
+ return Err("contract surface.wasm_crates must not be empty".to_string());
+ }
if bundle.exports.is_empty() {
return Err("at least one language export mapping is required".to_string());
}
@@ -136,10 +151,41 @@ pub fn validate_contract_bundle(bundle: &ContractBundle) -> Result<(), String> {
return Err("language.id is required".to_string());
}
if mapping.language.repository.trim().is_empty() {
- return Err(format!("language.repository is required for {}", mapping.language.id));
+ return Err(format!(
+ "language.repository is required for {}",
+ mapping.language.id
+ ));
}
if mapping.packages.is_empty() {
- return Err(format!("packages map is required for {}", mapping.language.id));
+ return Err(format!(
+ "packages map is required for {}",
+ mapping.language.id
+ ));
+ }
+ if mapping.language.id == "ts" {
+ let artifacts = mapping
+ .artifacts
+ .as_ref()
+ .ok_or_else(|| "artifacts map is required for ts".to_string())?;
+ if artifacts
+ .models_dir
+ .as_deref()
+ .is_none_or(|value| value.trim().is_empty())
+ || artifacts
+ .constants_dir
+ .as_deref()
+ .is_none_or(|value| value.trim().is_empty())
+ || artifacts
+ .wasm_dist_dir
+ .as_deref()
+ .is_none_or(|value| value.trim().is_empty())
+ || artifacts
+ .manifest_file
+ .as_deref()
+ .is_none_or(|value| value.trim().is_empty())
+ {
+ return Err("artifacts fields must be non-empty for ts".to_string());
+ }
}
}
if bundle.version.contract.version.trim().is_empty() {
diff --git a/crates/xtask/src/export_ts.rs b/crates/xtask/src/export_ts.rs
@@ -1,9 +1,23 @@
#![forbid(unsafe_code)]
use crate::contract;
+use serde::Serialize;
+use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
+#[derive(Serialize)]
+struct ExportManifest {
+ language: String,
+ files: Vec<ExportManifestEntry>,
+}
+
+#[derive(Serialize)]
+struct ExportManifestEntry {
+ path: String,
+ sha256: String,
+}
+
fn to_package_dir(base: &Path, package_name: &str) -> PathBuf {
let stripped = package_name
.strip_prefix("@radroots/")
@@ -11,6 +25,30 @@ fn to_package_dir(base: &Path, package_name: &str) -> PathBuf {
base.join(stripped)
}
+fn ts_export_mapping(
+ bundle: &contract::ContractBundle,
+) -> Result<&contract::ExportMapping, String> {
+ bundle
+ .exports
+ .iter()
+ .find(|mapping| mapping.language.id == "ts")
+ .ok_or_else(|| "missing ts export mapping".to_string())
+}
+
+fn ts_artifacts(mapping: &contract::ExportMapping) -> Result<&contract::ExportArtifacts, String> {
+ mapping
+ .artifacts
+ .as_ref()
+ .ok_or_else(|| "missing ts artifacts mapping".to_string())
+}
+
+fn required_artifact_value<'a>(value: &'a Option<String>, field: &str) -> Result<&'a str, String> {
+ value
+ .as_deref()
+ .filter(|item| !item.trim().is_empty())
+ .ok_or_else(|| format!("missing ts artifacts.{field}"))
+}
+
fn copy_if_exists(src: &Path, dst: &Path) -> Result<bool, String> {
if !src.exists() {
return Ok(false);
@@ -50,14 +88,56 @@ fn copy_dir_contents(src: &Path, dst: &Path) -> Result<usize, String> {
Ok(copied)
}
+fn collect_manifest_entries(
+ root: &Path,
+ current: &Path,
+ skip_path: &Path,
+ entries: &mut Vec<ExportManifestEntry>,
+) -> Result<(), String> {
+ if !current.exists() {
+ return Ok(());
+ }
+ let mut dir_entries = fs::read_dir(current)
+ .map_err(|e| format!("read dir {}: {e}", current.display()))?
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|e| format!("read dir entries {}: {e}", current.display()))?;
+ dir_entries.sort_by_key(|entry| entry.file_name());
+ for entry in dir_entries {
+ let path = entry.path();
+ if path == skip_path {
+ continue;
+ }
+ let file_type = entry
+ .file_type()
+ .map_err(|e| format!("read type {}: {e}", path.display()))?;
+ if file_type.is_dir() {
+ collect_manifest_entries(root, &path, skip_path, entries)?;
+ continue;
+ }
+ if !file_type.is_file() {
+ continue;
+ }
+ let bytes = fs::read(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
+ let digest = Sha256::digest(&bytes);
+ let relative = path
+ .strip_prefix(root)
+ .map_err(|e| format!("strip prefix {}: {e}", path.display()))?
+ .to_string_lossy()
+ .replace('\\', "/");
+ entries.push(ExportManifestEntry {
+ path: relative,
+ sha256: hex::encode(digest),
+ });
+ }
+ Ok(())
+}
+
pub fn export_ts_models(workspace_root: &Path, out_dir: &Path) -> Result<(), String> {
let bundle = contract::load_contract_bundle(workspace_root)?;
contract::validate_contract_bundle(&bundle)?;
- let ts_export = bundle
- .exports
- .iter()
- .find(|mapping| mapping.language.id == "ts")
- .ok_or_else(|| "missing ts export mapping".to_string())?;
+ let ts_export = ts_export_mapping(&bundle)?;
+ let artifacts = ts_artifacts(ts_export)?;
+ let models_dir = required_artifact_value(&artifacts.models_dir, "models_dir")?;
let source_root = workspace_root.join("target").join("ts-rs");
if !source_root.exists() {
return Err(format!(
@@ -71,8 +151,7 @@ pub fn export_ts_models(workspace_root: &Path, out_dir: &Path) -> Result<(), Str
let crate_dir = crate_name.strip_prefix("radroots-").unwrap_or(crate_name);
let src = source_root.join(crate_dir).join("types.ts");
let dst = to_package_dir(&ts_out_root, package_name)
- .join("src")
- .join("generated")
+ .join(models_dir)
.join("types.ts");
if copy_if_exists(&src, &dst)? {
copied += 1;
@@ -87,11 +166,9 @@ pub fn export_ts_models(workspace_root: &Path, out_dir: &Path) -> Result<(), Str
pub fn export_ts_constants(workspace_root: &Path, out_dir: &Path) -> Result<(), String> {
let bundle = contract::load_contract_bundle(workspace_root)?;
contract::validate_contract_bundle(&bundle)?;
- let ts_export = bundle
- .exports
- .iter()
- .find(|mapping| mapping.language.id == "ts")
- .ok_or_else(|| "missing ts export mapping".to_string())?;
+ let ts_export = ts_export_mapping(&bundle)?;
+ let artifacts = ts_artifacts(ts_export)?;
+ let constants_dir = required_artifact_value(&artifacts.constants_dir, "constants_dir")?;
let ts_out_root = out_dir.join("ts").join("packages");
for (crate_name, package_name) in &ts_export.packages {
let crate_dir = crate_name.strip_prefix("radroots-").unwrap_or(crate_name);
@@ -111,8 +188,7 @@ pub fn export_ts_constants(workspace_root: &Path, out_dir: &Path) -> Result<(),
.cloned()
.unwrap_or_else(|| candidates[0].clone());
let dst = to_package_dir(&ts_out_root, package_name)
- .join("src")
- .join("generated")
+ .join(constants_dir)
.join(filename);
copy_if_exists(&src, &dst)?;
}
@@ -123,11 +199,9 @@ pub fn export_ts_constants(workspace_root: &Path, out_dir: &Path) -> Result<(),
pub fn export_ts_wasm_artifacts(workspace_root: &Path, out_dir: &Path) -> Result<(), String> {
let bundle = contract::load_contract_bundle(workspace_root)?;
contract::validate_contract_bundle(&bundle)?;
- let ts_export = bundle
- .exports
- .iter()
- .find(|mapping| mapping.language.id == "ts")
- .ok_or_else(|| "missing ts export mapping".to_string())?;
+ let ts_export = ts_export_mapping(&bundle)?;
+ let artifacts = ts_artifacts(ts_export)?;
+ let wasm_dist_dir = required_artifact_value(&artifacts.wasm_dist_dir, "wasm_dist_dir")?;
let ts_out_root = out_dir.join("ts").join("packages");
let mut copied = 0usize;
for (crate_name, package_name) in &ts_export.packages {
@@ -140,7 +214,7 @@ pub fn export_ts_wasm_artifacts(workspace_root: &Path, out_dir: &Path) -> Result
.join(crate_dir)
.join("pkg")
.join("dist");
- let target_root = to_package_dir(&ts_out_root, package_name).join("dist");
+ let target_root = to_package_dir(&ts_out_root, package_name).join(wasm_dist_dir);
copied += copy_dir_contents(&source_root, &target_root)?;
}
if copied == 0 {
@@ -148,3 +222,32 @@ pub fn export_ts_wasm_artifacts(workspace_root: &Path, out_dir: &Path) -> Result
}
Ok(())
}
+
+pub fn write_ts_export_manifest(workspace_root: &Path, out_dir: &Path) -> Result<PathBuf, String> {
+ let bundle = contract::load_contract_bundle(workspace_root)?;
+ contract::validate_contract_bundle(&bundle)?;
+ let ts_export = ts_export_mapping(&bundle)?;
+ let artifacts = ts_artifacts(ts_export)?;
+ let manifest_file = required_artifact_value(&artifacts.manifest_file, "manifest_file")?;
+ let ts_root = out_dir.join("ts");
+ let manifest_path = ts_root.join(manifest_file);
+ let mut files = Vec::new();
+ collect_manifest_entries(
+ &ts_root,
+ &ts_root.join("packages"),
+ &manifest_path,
+ &mut files,
+ )?;
+ let manifest = ExportManifest {
+ language: ts_export.language.id.clone(),
+ files,
+ };
+ if let Some(parent) = manifest_path.parent() {
+ fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
+ }
+ let bytes = serde_json::to_vec_pretty(&manifest)
+ .map_err(|e| format!("serialize manifest {}: {e}", manifest_path.display()))?;
+ fs::write(&manifest_path, bytes)
+ .map_err(|e| format!("write {}: {e}", manifest_path.display()))?;
+ Ok(manifest_path)
+}
diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs
@@ -12,6 +12,7 @@ fn usage() {
eprintln!(" cargo xtask sdk export-ts-models [--out <dir>]");
eprintln!(" cargo xtask sdk export-ts-constants [--out <dir>]");
eprintln!(" cargo xtask sdk export-ts-wasm [--out <dir>]");
+ eprintln!(" cargo xtask sdk export-manifest [--out <dir>]");
eprintln!(" cargo xtask sdk validate");
}
@@ -60,6 +61,14 @@ fn export_ts_wasm(args: &[String]) -> Result<(), String> {
Ok(())
}
+fn export_manifest(args: &[String]) -> Result<(), String> {
+ let root = workspace_root()?;
+ let out_dir = parse_out_dir(args, &root)?;
+ let manifest = export_ts::write_ts_export_manifest(&root, &out_dir)?;
+ eprintln!("wrote export manifest {}", manifest.display());
+ Ok(())
+}
+
fn validate_contract() -> Result<(), String> {
let root = workspace_root()?;
let bundle = contract::load_contract_bundle(&root)?;
@@ -77,6 +86,7 @@ fn run_sdk(args: &[String]) -> Result<(), String> {
Some("export-ts-models") => export_ts_models(&args[1..]),
Some("export-ts-constants") => export_ts_constants(&args[1..]),
Some("export-ts-wasm") => export_ts_wasm(&args[1..]),
+ Some("export-manifest") => export_manifest(&args[1..]),
Some("validate") => validate_contract(),
_ => Err("unknown sdk subcommand".to_string()),
}