tests/release_pipeline.rs
//! End-to-end release pipeline smoke-test.
//!
//! Creates two trivial cargo crates side-by-side in a tempdir, wires
//! them as a nornir workspace, runs the dep-graph + release pipeline
//! in dry-run mode, then reopens the warehouse and asserts the
//! lineage round-trips with non-empty git SHAs.
//!
//! This deliberately avoids cargo-test-on-holger to stay fast.
use std::path::Path;
use nornir::release::pipeline::{self, PipelineOptions};
use nornir::warehouse::dep_graph;
use nornir::warehouse::iceberg::IcebergWarehouse;
use nornir::workspace::WorkspaceDescriptor;
fn write(p: &Path, body: &str) {
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(p, body).unwrap();
}
fn git_init(root: &Path) {
// Pure-Rust fixture setup via gix — no `git` subprocess anywhere in
// the crate, tests included. `commit_all` snapshots the worktree and
// creates the initial commit on a fresh repo.
nornir::gitio::init(root).unwrap();
nornir::gitio::commit_all(root, "init").unwrap();
}
#[test]
fn release_pipeline_dry_run_records_lineage() {
let tmp = tempfile::tempdir().unwrap();
// ── repo A: produces crate `acme-lib` ───────────────────────────
let a = tmp.path().join("acme");
write(
&a.join("Cargo.toml"),
r#"[package]
name = "acme-lib"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
path = "src/lib.rs"
"#,
);
write(&a.join("src/lib.rs"), "pub fn one() -> u32 { 1 }\n");
git_init(&a);
// ── repo B: consumes acme-lib via path dep ─────────────────────
let b = tmp.path().join("widget");
write(
&b.join("Cargo.toml"),
&format!(
r#"[package]
name = "widget-lib"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
path = "src/lib.rs"
[dependencies]
acme-lib = {{ path = "{}" }}
"#,
a.display()
),
);
write(
&b.join("src/lib.rs"),
"pub fn two() -> u32 { acme_lib::one() + 1 }\n",
);
git_init(&b);
// ── workspace descriptor ───────────────────────────────────────
let toml = format!(
r#"[workspace]
name = "acme-suite"
[repos.acme]
path = "{}"
[repos.widget]
path = "{}"
"#,
a.display(),
b.display()
);
let toml_path = tmp.path().join("nornir-workspace.toml");
std::fs::write(&toml_path, toml).unwrap();
let desc = WorkspaceDescriptor::load(&toml_path).unwrap();
let graph = dep_graph::build(&desc).unwrap();
// sanity: build order must put acme before widget
let order = graph.build_order().unwrap();
let ai = order.iter().position(|n| n == "acme").unwrap();
let bi = order.iter().position(|n| n == "widget").unwrap();
assert!(ai < bi, "expected acme before widget; got {order:?}");
// ── persist dep graph + run release ────────────────────────────
let wh_root = tmp.path().join("warehouse-root");
let wh = IcebergWarehouse::open(&wh_root).unwrap();
let dep_snap = wh
.block_on(dep_graph::record_dep_graph(&wh, "acme-suite", &graph))
.unwrap();
let report = wh
.block_on(pipeline::run_pipeline(
&wh,
&desc,
&graph,
&PipelineOptions {
dry_run: true,
dep_graph_snapshot_id: dep_snap,
refuse_if_dirty: true,
progress: None,
gate_config: None,
},
))
.unwrap();
assert_eq!(report.repos.len(), 2);
for r in &report.repos {
assert_eq!(r.gate_status, "succeeded_dry_run", "{r:?}");
assert_eq!(r.git.sha.len(), 40, "expected full 40-char git SHA: {r:?}");
assert_eq!(r.tests_failed, 0);
}
// build_order_idx must be deps-first
assert_eq!(report.repos[0].repo, "acme");
assert_eq!(report.repos[1].repo, "widget");
// ── reopen warehouse, query lineage back ───────────────────────
drop(wh);
let wh2 = IcebergWarehouse::open(&wh_root).unwrap();
let history = wh2
.block_on(pipeline::query_release_history(&wh2, "acme-suite", None))
.unwrap();
assert_eq!(history.len(), 1);
let h = &history[0];
assert_eq!(h.release_id, report.release_id);
assert_eq!(h.repos.len(), 2);
assert_eq!(h.repos[0].repo, "acme");
assert_eq!(h.repos[1].repo, "widget");
// The shipped SHAs are the rollback handle:
assert_eq!(h.repos[0].git.sha, report.repos[0].git.sha);
assert_eq!(h.repos[1].git.sha, report.repos[1].git.sha);
// run_pipeline doesn't capture index/dwarf, so its pins are None — and
// they must round-trip as None (asserts the pin columns read cleanly).
assert!(h.repos[0].tantivy_snapshot_id.is_none());
assert!(h.repos[0].dwarf_snapshot_id.is_none());
}
/// The Urðr time-machine pins (`tantivy_snapshot_id` / `dwarf_snapshot_id`)
/// must survive a write→reopen→read round-trip through `release_lineage`.
/// This guards the fragile arrow column indices (14/15) the capture phase
/// depends on to make a release restorable.
#[test]
fn release_lineage_pins_round_trip() {
use nornir::release::pipeline::{
RepoGitState, RepoReleaseRecord, persist_lineage, query_release_history,
};
let tmp = tempfile::tempdir().unwrap();
let wh_root = tmp.path().join("warehouse-root");
let wh = IcebergWarehouse::open(&wh_root).unwrap();
let release_id = uuid::Uuid::new_v4();
let dep_snap = uuid::Uuid::new_v4();
let records = vec![RepoReleaseRecord {
repo: "acme".into(),
build_order_idx: 0,
git: RepoGitState {
sha: "a".repeat(40),
branch: "main".into(),
dirty: false,
},
gate_status: "succeeded".into(),
tests_passed: 3,
tests_failed: 0,
published_versions: vec![],
tantivy_snapshot_id: Some("tantivy-snap-123".into()),
dwarf_snapshot_id: Some("dwarf-snap-456".into()),
}];
wh.block_on(persist_lineage(
&wh,
release_id,
"acme-suite",
&dep_snap,
&records,
false,
))
.unwrap();
drop(wh);
let wh2 = IcebergWarehouse::open(&wh_root).unwrap();
let history = wh2
.block_on(query_release_history(&wh2, "acme-suite", None))
.unwrap();
assert_eq!(history.len(), 1);
let r = &history[0].repos[0];
assert_eq!(r.tantivy_snapshot_id.as_deref(), Some("tantivy-snap-123"));
assert_eq!(r.dwarf_snapshot_id.as_deref(), Some("dwarf-snap-456"));
}