use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::io;
use std::path::Path;
use super::*;
use crate::contract::schema::{Adapter, Ecosystem, Registry, Target};
use crate::ports::{Clock, CommandOutput, CommandRunner, RegistryQuery};
use crate::protocol::release::{PublishReceipt, VerifyOutcome};
struct FakeCmd {
calls: RefCell<Vec<String>>,
fail: Option<(String, i32, String)>,
spawn_err: bool,
metadata: Option<String>,
fail_containing: Option<(String, i32, String)>,
stdout_containing: Option<(String, String)>,
clone_seed: Option<(String, String)>,
clone_symlink: Option<String>,
}
impl FakeCmd {
fn new() -> Self {
Self {
calls: RefCell::new(Vec::new()),
fail: None,
spawn_err: false,
metadata: None,
fail_containing: None,
stdout_containing: None,
clone_seed: None,
clone_symlink: None,
}
}
fn seed_clone(mut self, rel: &str, content: &str) -> Self {
self.clone_seed = Some((rel.to_string(), content.to_string()));
self
}
fn seed_clone_symlink(mut self, rel: &str) -> Self {
self.clone_symlink = Some(rel.to_string());
self
}
fn fail_calls_containing(mut self, needle: &str, code: i32, stderr: &str) -> Self {
self.fail_containing = Some((needle.to_string(), code, stderr.to_string()));
self
}
fn stdout_calls_containing(mut self, needle: &str, stdout: &str) -> Self {
self.stdout_containing = Some((needle.to_string(), stdout.to_string()));
self
}
fn failing(program: &str, code: i32, stderr: &str) -> Self {
Self {
fail: Some((program.to_string(), code, stderr.to_string())),
..Self::new()
}
}
fn spawn_error() -> Self {
Self {
spawn_err: true,
..Self::new()
}
}
fn with_metadata(mut self, json: &str) -> Self {
self.metadata = Some(json.to_string());
self
}
fn calls(&self) -> Vec<String> {
self.calls.borrow().clone()
}
}
impl CommandRunner for FakeCmd {
fn run(&self, program: &str, args: &[&str], _cwd: &Path) -> io::Result<CommandOutput> {
let rendered = format!("{program} {}", args.join(" "));
self.calls.borrow_mut().push(rendered.clone());
if self.spawn_err {
return Err(io::Error::from(io::ErrorKind::NotFound));
}
if program == "gh" && args.first() == Some(&"repo") && args.get(1) == Some(&"clone") {
if let Some(workdir) = args.get(3) {
let base = Path::new(workdir);
if let Some((rel, content)) = &self.clone_seed {
let path = base.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, content).unwrap();
}
if let Some(rel) = &self.clone_symlink {
let path = base.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
let target = base.join("__symlink_target");
std::fs::write(&target, "target\n").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &path).unwrap();
}
}
return Ok(CommandOutput {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
});
}
if let Some((needle, code, stderr)) = &self.fail_containing {
if rendered.contains(needle) {
return Ok(CommandOutput {
status: Some(*code),
stdout: String::new(),
stderr: stderr.clone(),
});
}
}
if let Some((needle, stdout)) = &self.stdout_containing {
if rendered.contains(needle) {
return Ok(CommandOutput {
status: Some(0),
stdout: stdout.clone(),
stderr: String::new(),
});
}
}
if let Some((p, code, stderr)) = &self.fail {
if p == program {
return Ok(CommandOutput {
status: Some(*code),
stdout: String::new(),
stderr: stderr.clone(),
});
}
}
if args.contains(&"metadata") {
return Ok(CommandOutput {
status: Some(0),
stdout: self.metadata.clone().unwrap_or_default(),
stderr: String::new(),
});
}
Ok(CommandOutput {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
})
}
}
struct FakeClock(u64);
impl Clock for FakeClock {
fn now_unix(&self) -> u64 {
self.0
}
}
struct AdvancingClock(Cell<u64>);
impl AdvancingClock {
fn new() -> Self {
Self(Cell::new(0))
}
}
impl Clock for AdvancingClock {
fn now_unix(&self) -> u64 {
self.0.get()
}
fn sleep(&self, dur: Duration) {
self.0.set(self.0.get() + dur.as_secs().max(1));
}
}
struct SeqRegistry {
schedule: RefCell<HashMap<String, (String, u32)>>,
}
impl SeqRegistry {
fn new() -> Self {
Self {
schedule: RefCell::new(HashMap::new()),
}
}
fn after(self, package: &str, version: &str, polls: u32) -> Self {
self.schedule
.borrow_mut()
.insert(package.to_string(), (version.to_string(), polls));
self
}
}
impl RegistryQuery for SeqRegistry {
fn published_versions(&self, _ecosystem: &str, package: &str) -> io::Result<Vec<String>> {
let mut sched = self.schedule.borrow_mut();
match sched.get_mut(package) {
Some((version, remaining)) => {
if *remaining == 0 {
Ok(vec![version.clone()])
} else {
*remaining -= 1;
Ok(Vec::new())
}
}
None => Ok(Vec::new()),
}
}
}
struct AlternatingRegistry {
calls: Cell<u32>,
}
impl AlternatingRegistry {
fn new() -> Self {
Self {
calls: Cell::new(0),
}
}
}
impl RegistryQuery for AlternatingRegistry {
fn published_versions(&self, _ecosystem: &str, _package: &str) -> io::Result<Vec<String>> {
let n = self.calls.get();
self.calls.set(n + 1);
if n % 2 == 0 {
Ok(Vec::new())
} else {
Err(io::Error::from(io::ErrorKind::TimedOut))
}
}
}
struct AbsentThenErrRegistry {
remaining_ok: Cell<u32>,
}
impl AbsentThenErrRegistry {
fn new(ok_count: u32) -> Self {
Self {
remaining_ok: Cell::new(ok_count),
}
}
}
impl RegistryQuery for AbsentThenErrRegistry {
fn published_versions(&self, _ecosystem: &str, _package: &str) -> io::Result<Vec<String>> {
let left = self.remaining_ok.get();
if left == 0 {
return Err(io::Error::from(io::ErrorKind::TimedOut));
}
self.remaining_ok.set(left - 1);
Ok(Vec::new())
}
}
struct ErrForRegistry {
err_package: String,
}
impl ErrForRegistry {
fn new(err_package: &str) -> Self {
Self {
err_package: err_package.to_string(),
}
}
}
impl RegistryQuery for ErrForRegistry {
fn published_versions(&self, _ecosystem: &str, package: &str) -> io::Result<Vec<String>> {
if package == self.err_package {
Err(io::Error::from(io::ErrorKind::TimedOut))
} else {
Ok(Vec::new())
}
}
}
struct FakeRegistry {
versions: HashMap<(String, String), Vec<String>>,
err: bool,
queries: RefCell<Vec<String>>,
}
impl FakeRegistry {
fn new() -> Self {
Self {
versions: HashMap::new(),
err: false,
queries: RefCell::new(Vec::new()),
}
}
fn with(mut self, ecosystem: &str, package: &str, versions: &[&str]) -> Self {
self.versions.insert(
(ecosystem.to_string(), package.to_string()),
versions.iter().map(|s| (*s).to_string()).collect(),
);
self
}
fn erroring() -> Self {
Self {
err: true,
..Self::new()
}
}
fn queries(&self) -> Vec<String> {
self.queries.borrow().clone()
}
}
impl RegistryQuery for FakeRegistry {
fn published_versions(&self, ecosystem: &str, package: &str) -> io::Result<Vec<String>> {
self.queries
.borrow_mut()
.push(format!("{ecosystem}:{package}"));
if self.err {
return Err(io::Error::from(io::ErrorKind::TimedOut));
}
Ok(self
.versions
.get(&(ecosystem.to_string(), package.to_string()))
.cloned()
.unwrap_or_default())
}
}
struct SkipAuthRegistry {
version: String,
checksum: Option<String>,
}
impl SkipAuthRegistry {
fn new(version: &str, checksum: Option<&str>) -> Self {
Self {
version: version.to_string(),
checksum: checksum.map(str::to_string),
}
}
}
impl RegistryQuery for SkipAuthRegistry {
fn published_versions(&self, _ecosystem: &str, _package: &str) -> io::Result<Vec<String>> {
Ok(vec![self.version.clone()])
}
fn published_checksum(
&self,
_ecosystem: &str,
_package: &str,
_version: &str,
) -> io::Result<String> {
self.checksum
.clone()
.ok_or_else(|| io::Error::from(io::ErrorKind::TimedOut))
}
}
fn ctx<'a>(
runner: &'a FakeCmd,
clock: &'a FakeClock,
registry: &'a FakeRegistry,
root: &'a Path,
) -> EffectCtx<'a> {
EffectCtx {
runner,
clock,
registry,
repo_root: root,
artifacts: &EMPTY_ARTIFACTS,
}
}
fn ctx_with<'a>(
runner: &'a FakeCmd,
clock: &'a FakeClock,
registry: &'a FakeRegistry,
root: &'a Path,
artifacts: &'a ReleaseArtifacts,
) -> EffectCtx<'a> {
EffectCtx {
runner,
clock,
registry,
repo_root: root,
artifacts,
}
}
fn ctx_dyn<'a>(
runner: &'a FakeCmd,
clock: &'a dyn Clock,
registry: &'a dyn RegistryQuery,
root: &'a Path,
) -> EffectCtx<'a> {
EffectCtx {
runner,
clock,
registry,
repo_root: root,
artifacts: &EMPTY_ARTIFACTS,
}
}
fn ctx_advancing<'a>(
runner: &'a FakeCmd,
clock: &'a AdvancingClock,
registry: &'a dyn RegistryQuery,
root: &'a Path,
) -> EffectCtx<'a> {
EffectCtx {
runner,
clock,
registry,
repo_root: root,
artifacts: &EMPTY_ARTIFACTS,
}
}
fn target(
ecosystem: Ecosystem,
registry: Registry,
adapter: Adapter,
version: &str,
) -> AdapterTarget {
target_named(ecosystem, registry, adapter, "tool", version)
}
fn target_named(
ecosystem: Ecosystem,
registry: Registry,
adapter: Adapter,
package: &str,
version: &str,
) -> AdapterTarget {
AdapterTarget {
target: Target {
ecosystem,
package: Some(package.to_string()),
registry,
adapter,
},
package: package.to_string(),
version: version.to_string(),
}
}
fn metadata_single(name: &str, version: &str) -> String {
let id = format!("{name} {version}");
format!(
r#"{{"packages":[{{"name":"{name}","version":"{version}","id":"{id}","dependencies":[],"publish":null}}],"workspace_members":["{id}"]}}"#
)
}
fn metadata_two_crate(lib: &str, bin: &str, version: &str) -> String {
let lib_id = format!("{lib} {version}");
let bin_id = format!("{bin} {version}");
format!(
r#"{{"packages":[
{{"name":"{bin}","version":"{version}","id":"{bin_id}","publish":null,
"dependencies":[{{"name":"{lib}","kind":null}}]}},
{{"name":"{lib}","version":"{version}","id":"{lib_id}","publish":null,
"dependencies":[{{"name":"{bin}","kind":"dev"}}]}}
],"workspace_members":["{lib_id}","{bin_id}"]}}"#
)
}
fn metadata_single_with_target(name: &str, version: &str, target_dir: &str) -> String {
let id = format!("{name} {version}");
format!(
r#"{{"packages":[{{"name":"{name}","version":"{version}","id":"{id}","dependencies":[],"publish":null}}],"workspace_members":["{id}"],"target_directory":"{target_dir}"}}"#
)
}
fn receipt(ecosystem: Ecosystem, version: &str, digest: Option<&str>) -> PublishReceipt {
PublishReceipt {
adapter: Adapter::CargoPublish,
ecosystem,
package: "tool".to_string(),
version: version.to_string(),
canonical_ref: format!("crates.io/tool@{version}"),
digest: digest.map(str::to_string),
remote_url: None,
timestamp: 100,
}
}
#[test]
fn resolve_dispatches_every_adapter_identity() {
let cases = [
(Adapter::CargoPublish, "rust"),
(Adapter::CargoDist, "rust"),
(Adapter::ReleasePlease, "node"),
(Adapter::Changesets, "node"),
(Adapter::NpmPublish, "node"),
(Adapter::GhActionPypiPublish, "python"),
(Adapter::Twine, "python"),
(Adapter::Goreleaser, "go"),
(Adapter::HomebrewTap, "homebrew"),
(Adapter::HomebrewCore, "homebrew"),
(Adapter::Manual, "binary"),
];
for (id, family) in cases {
let resolved = resolve(id);
assert_eq!(resolved.adapter(), id, "identity round-trips for {id:?}");
let actual = match resolved {
EcosystemAdapter::Rust(_) => "rust",
EcosystemAdapter::Node(_) => "node",
EcosystemAdapter::Python(_) => "python",
EcosystemAdapter::Go(_) => "go",
EcosystemAdapter::Homebrew(_) => "homebrew",
EcosystemAdapter::Binary(_) => "binary",
};
assert_eq!(actual, family, "{id:?} routes to {family}");
}
}
#[test]
fn every_adapter_has_a_nonzero_timeout() {
for id in Adapter::VALID.iter().filter_map(|s| Adapter::parse(s)) {
assert!(
resolve(id).timeout() > Duration::ZERO,
"{id:?} must declare a timeout"
);
}
}
#[test]
fn dry_run_runs_the_index_independent_build_gate_as_preflight() {
let cmd = FakeCmd::new().with_metadata(&metadata_single("tool", "1.2.3"));
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"1.2.3",
);
let report = resolve(Adapter::CargoPublish).dry_run(&c, &t).unwrap();
assert_eq!(report.adapter, Adapter::CargoPublish);
let rendered: Vec<String> = report
.planned_commands
.iter()
.map(crate::protocol::release::PlannedCommand::rendered)
.collect();
assert_eq!(
rendered,
vec![
"cargo check -p tool".to_string(),
"cargo package --registry crates-io -p tool --no-verify".to_string(),
]
);
assert_eq!(
cmd.calls(),
vec![
"cargo metadata --no-deps --format-version 1",
"cargo check -p tool",
"cargo package --registry crates-io -p tool --no-verify",
]
);
assert!(
!cmd.calls().iter().any(|call| call.contains("publish")),
"dry-run executed a publish: {:?}",
cmd.calls()
);
assert!(
report
.notes
.iter()
.any(|n| n.contains("cargo publish --registry crates-io -p tool")),
"dry-run dropped the publish note: {:?}",
report.notes
);
}
#[test]
fn dry_run_available_for_every_ecosystem() {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
for (eco, registry, id) in [
(Ecosystem::Rust, Registry::CratesIo, Adapter::CargoDist),
(Ecosystem::Node, Registry::Npm, Adapter::NpmPublish),
(Ecosystem::Node, Registry::Npm, Adapter::ReleasePlease),
(Ecosystem::Python, Registry::Pypi, Adapter::Twine),
(
Ecosystem::Python,
Registry::Pypi,
Adapter::GhActionPypiPublish,
),
(Ecosystem::Go, Registry::ProxyGolangOrg, Adapter::Goreleaser),
(Ecosystem::Binary, Registry::Homebrew, Adapter::HomebrewTap),
(Ecosystem::Binary, Registry::GhReleases, Adapter::Manual),
] {
let t = target(eco, registry, id, "1.0.0");
let report = resolve(id).dry_run(&c, &t).unwrap();
assert_eq!(report.adapter, id);
assert!(
!report.planned_commands.is_empty(),
"{id:?} dry_run planned no commands"
);
}
assert!(cmd.calls().is_empty(), "a dry_run executed a command");
}
#[test]
fn publish_runs_the_registry_command_and_returns_a_receipt() {
let cmd = FakeCmd::new().with_metadata(&metadata_single("tool", "2.0.0"));
let clock = FakeClock(42);
let reg = SeqRegistry::new().after("tool", "2.0.0", 1);
let root = Path::new("/repo");
let c = ctx_dyn(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"2.0.0",
);
let r = resolve(Adapter::CargoPublish).publish(&c, &t).unwrap();
assert_eq!(
cmd.calls(),
vec![
"cargo metadata --no-deps --format-version 1",
"cargo publish --registry crates-io -p tool"
]
);
assert_eq!(r.adapter, Adapter::CargoPublish);
assert_eq!(r.ecosystem, Ecosystem::Rust);
assert_eq!(r.package, "tool");
assert_eq!(r.version, "2.0.0");
assert_eq!(r.canonical_ref, "crates.io/tool@2.0.0");
assert_eq!(r.timestamp, 42, "receipt stamps the injected clock");
assert_eq!(
r.remote_url.as_deref(),
Some("https://crates.io/crates/tool/2.0.0")
);
}
#[test]
fn cargo_build_packages_a_leaf_crate() {
let cmd = FakeCmd::new().with_metadata(&metadata_single("tool", "1.2.3"));
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"1.2.3",
);
let b = resolve(Adapter::CargoPublish).build(&c, &t).unwrap();
assert_eq!(b.adapter, Adapter::CargoPublish);
assert_eq!(b.artifacts, vec!["tool-1.2.3.crate".to_string()]);
assert_eq!(
cmd.calls(),
vec![
"cargo metadata --no-deps --format-version 1",
"cargo check -p tool",
"cargo package --registry crates-io -p tool --no-verify",
]
);
assert!(reg.queries().is_empty(), "build queried the registry index");
}
#[test]
fn cargo_build_defers_packaging_for_a_dependent_on_an_unpublished_crate() {
let cmd = FakeCmd::new().with_metadata(&metadata_two_crate("lib", "bin", "0.2.0"));
let clock = FakeClock(1);
let reg = FakeRegistry::new(); let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target_named(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"bin",
"0.2.0",
);
let b = resolve(Adapter::CargoPublish).build(&c, &t).unwrap();
assert!(
b.artifacts.is_empty(),
"a deferred build produced an artifact"
);
assert_eq!(
cmd.calls(),
vec![
"cargo metadata --no-deps --format-version 1",
"cargo check -p bin",
]
);
assert!(
b.notes.iter().any(|n| n.contains("deferred")),
"build dropped the deferred-packaging note: {:?}",
b.notes
);
assert_eq!(reg.queries(), vec!["rust:lib".to_string()]);
}
#[test]
fn cargo_build_packages_a_dependent_whose_dep_is_already_published() {
let cmd = FakeCmd::new().with_metadata(&metadata_two_crate("lib", "bin", "0.2.0"));
let clock = FakeClock(1);
let reg = FakeRegistry::new().with("rust", "lib", &["0.2.0"]); let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target_named(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"bin",
"0.2.0",
);
let b = resolve(Adapter::CargoPublish).build(&c, &t).unwrap();
assert_eq!(b.artifacts, vec!["bin-0.2.0.crate".to_string()]);
assert!(
b.notes.is_empty(),
"an already-packageable dependent should not carry a deferral note: {:?}",
b.notes
);
assert_eq!(
cmd.calls(),
vec![
"cargo metadata --no-deps --format-version 1",
"cargo check -p bin",
"cargo package --registry crates-io -p bin --no-verify",
]
);
assert_eq!(reg.queries(), vec!["rust:lib".to_string()]);
}
#[test]
fn build_phase_clears_both_crates_when_a_pinned_dep_is_not_yet_published() {
let cmd = FakeCmd::new().with_metadata(&metadata_two_crate("lib", "bin", "0.2.0"));
let clock = FakeClock(1);
let reg = FakeRegistry::new(); let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let lib = target_named(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"lib",
"0.2.0",
);
let lib_build = resolve(Adapter::CargoPublish).build(&c, &lib).unwrap();
assert_eq!(lib_build.artifacts, vec!["lib-0.2.0.crate".to_string()]);
let c = ctx(&cmd, &clock, ®, root);
let bin = target_named(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"bin",
"0.2.0",
);
let bin_build = resolve(Adapter::CargoPublish).build(&c, &bin).unwrap();
assert!(
bin_build.artifacts.is_empty(),
"the dependent packaged in build-all instead of deferring"
);
assert_eq!(
cmd.calls(),
vec![
"cargo metadata --no-deps --format-version 1",
"cargo check -p lib",
"cargo package --registry crates-io -p lib --no-verify",
"cargo metadata --no-deps --format-version 1",
"cargo check -p bin",
]
);
assert_eq!(reg.queries(), vec!["rust:lib".to_string()]);
}
#[test]
fn dry_run_preflights_a_dependent_without_the_pinned_dep_on_the_index() {
let cmd = FakeCmd::new().with_metadata(&metadata_two_crate("lib", "bin", "0.2.0"));
let clock = FakeClock(1);
let reg = FakeRegistry::new(); let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target_named(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"bin",
"0.2.0",
);
let report = resolve(Adapter::CargoPublish).dry_run(&c, &t).unwrap();
assert_eq!(
cmd.calls(),
vec![
"cargo metadata --no-deps --format-version 1",
"cargo check -p bin",
]
);
assert_eq!(reg.queries(), vec!["rust:lib".to_string()]);
assert!(
report.notes.iter().any(|n| n.contains("deferred")),
"dry-run dropped the deferred-packaging note: {:?}",
report.notes
);
assert!(
report.notes.iter().any(|n| n.contains("lib@0.2.0")),
"dry-run dropped the workspace-dep wait note: {:?}",
report.notes
);
}
#[test]
fn dry_run_fails_when_the_package_preflight_fails() {
let cmd = FakeCmd::new()
.with_metadata(&metadata_single("tool", "1.2.3"))
.fail_calls_containing("cargo package", 101, "error: could not compile `tool`");
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"1.2.3",
);
let err = resolve(Adapter::CargoPublish).dry_run(&c, &t).unwrap_err();
match err {
AdapterError::Command { code, .. } => assert_eq!(code, Some(101)),
other => panic!("expected a Command error from the package preflight, got {other:?}"),
}
}
#[test]
fn build_phase_fails_on_a_genuine_compile_error_before_any_publish() {
let cmd = FakeCmd::new()
.with_metadata(&metadata_single("tool", "1.2.3"))
.fail_calls_containing("cargo check", 101, "error[E0308]: mismatched types");
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"1.2.3",
);
let err = resolve(Adapter::CargoPublish).build(&c, &t).unwrap_err();
match err {
AdapterError::Command { code, .. } => assert_eq!(code, Some(101)),
other => panic!("expected a Command error from the failing compile, got {other:?}"),
}
assert_eq!(
cmd.calls(),
vec![
"cargo metadata --no-deps --format-version 1",
"cargo check -p tool",
]
);
}
#[test]
fn build_phase_propagates_a_real_package_failure() {
let cmd = FakeCmd::new()
.with_metadata(&metadata_single("tool", "1.2.3"))
.fail_calls_containing(
"cargo package",
101,
"error: invalid inclusion of reserved file name",
);
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"1.2.3",
);
let err = resolve(Adapter::CargoPublish).build(&c, &t).unwrap_err();
match err {
AdapterError::Command { code, .. } => assert_eq!(code, Some(101)),
other => panic!("expected a Command error from the failing package, got {other:?}"),
}
assert_eq!(
cmd.calls(),
vec![
"cargo metadata --no-deps --format-version 1",
"cargo check -p tool",
"cargo package --registry crates-io -p tool --no-verify",
]
);
}
#[test]
fn cargo_rejects_a_non_crates_io_registry_before_any_action() {
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let t = target(
Ecosystem::Rust,
Registry::Npm,
Adapter::CargoPublish,
"1.0.0",
);
for op in ["dry_run", "build", "publish"] {
let cmd = FakeCmd::new();
let c = ctx(&cmd, &clock, ®, root);
let adapter = resolve(Adapter::CargoPublish);
let err = match op {
"dry_run" => adapter.dry_run(&c, &t).unwrap_err(),
"build" => adapter.build(&c, &t).unwrap_err(),
_ => adapter.publish(&c, &t).unwrap_err(),
};
match err {
AdapterError::UnsupportedRegistry { adapter, registry } => {
assert_eq!(adapter, Adapter::CargoPublish);
assert_eq!(registry, Registry::Npm);
}
other => panic!("{op}: expected UnsupportedRegistry, got {other:?}"),
}
assert!(
cmd.calls().is_empty(),
"{op} shelled out before rejecting a non-crates.io registry: {:?}",
cmd.calls()
);
}
}
#[test]
fn publish_propagates_a_command_failure() {
let cmd = FakeCmd::failing("npm", 1, "402 Payment Required");
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target(Ecosystem::Node, Registry::Npm, Adapter::NpmPublish, "1.0.0");
let err = resolve(Adapter::NpmPublish).publish(&c, &t).unwrap_err();
match err {
AdapterError::Command { code, .. } => assert_eq!(code, Some(1)),
other => panic!("expected Command error, got {other:?}"),
}
}
#[test]
fn publish_surfaces_a_spawn_failure_as_io_error() {
let cmd = FakeCmd::spawn_error();
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target(Ecosystem::Node, Registry::Npm, Adapter::NpmPublish, "1.0.0");
let err = resolve(Adapter::NpmPublish).publish(&c, &t).unwrap_err();
assert!(matches!(err, AdapterError::Io { .. }));
}
#[test]
fn cargo_dist_publish_is_unsupported_from_host() {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoDist,
"1.0.0",
);
let err = resolve(Adapter::CargoDist).publish(&c, &t).unwrap_err();
assert!(matches!(
err,
AdapterError::Unsupported {
operation: "publish",
..
}
));
assert!(cmd.calls().is_empty());
}
#[test]
fn release_please_publish_is_unsupported_from_host() {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Node,
Registry::Npm,
Adapter::ReleasePlease,
"1.0.0",
);
let err = resolve(Adapter::ReleasePlease).publish(&c, &t).unwrap_err();
assert!(
matches!(
err,
AdapterError::Unsupported {
adapter: Adapter::ReleasePlease,
operation: "publish",
}
),
"expected Unsupported publish for release-please, got {err:?}"
);
assert!(
cmd.calls().is_empty(),
"an unsupported publish must run no command"
);
}
#[test]
fn npm_publish_runs_the_real_publish_and_returns_a_receipt() {
let cmd = FakeCmd::new();
let clock = FakeClock(7);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target(Ecosystem::Node, Registry::Npm, Adapter::NpmPublish, "1.0.0");
let r = resolve(Adapter::NpmPublish).publish(&c, &t).unwrap();
assert_eq!(cmd.calls(), vec!["npm publish".to_string()]);
assert_eq!(r.package, "tool");
assert_eq!(r.version, "1.0.0");
assert_eq!(
r.remote_url.as_deref(),
Some("https://www.npmjs.com/package/tool/v/1.0.0")
);
}
#[test]
fn changeset_publish_runs_the_real_publish() {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target(Ecosystem::Node, Registry::Npm, Adapter::Changesets, "1.0.0");
resolve(Adapter::Changesets).publish(&c, &t).unwrap();
assert_eq!(cmd.calls(), vec!["changeset publish".to_string()]);
}
#[test]
fn node_build_reads_the_real_tarball_name_from_npm_pack_json() {
let cmd = FakeCmd::new().stdout_calls_containing(
"pack",
r#"[{"filename":"scope-pkg-1.0.0.tgz","name":"@scope/pkg","version":"1.0.0"}]"#,
);
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target_named(
Ecosystem::Node,
Registry::Npm,
Adapter::NpmPublish,
"@scope/pkg",
"1.0.0",
);
let b = resolve(Adapter::NpmPublish).build(&c, &t).unwrap();
assert_eq!(cmd.calls(), vec!["npm pack --json".to_string()]);
assert_eq!(b.artifacts, vec!["scope-pkg-1.0.0.tgz".to_string()]);
assert!(b.notes.is_empty());
}
#[test]
fn node_build_errors_when_npm_pack_emits_no_parseable_json() {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target(Ecosystem::Node, Registry::Npm, Adapter::NpmPublish, "1.0.0");
let err = resolve(Adapter::NpmPublish).build(&c, &t).unwrap_err();
assert!(
matches!(err, AdapterError::Command { code: None, .. }),
"expected a hard Command error on unparseable npm pack output, got {err:?}"
);
}
#[test]
fn node_build_errors_on_an_empty_npm_pack_json_array() {
let cmd = FakeCmd::new().stdout_calls_containing("pack", "[]");
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target(Ecosystem::Node, Registry::Npm, Adapter::NpmPublish, "1.0.0");
let err = resolve(Adapter::NpmPublish).build(&c, &t).unwrap_err();
assert!(matches!(err, AdapterError::Command { code: None, .. }));
}
#[test]
fn target_waits_for_its_workspace_deps_before_publishing_only_its_own_crate() {
let cmd = FakeCmd::new().with_metadata(&metadata_two_crate("lib", "bin", "1.0.0"));
let clock = AdvancingClock::new();
let reg = SeqRegistry::new()
.after("lib", "1.0.0", 2)
.after("bin", "1.0.0", 1);
let root = Path::new("/repo");
let c = ctx_advancing(&cmd, &clock, ®, root);
let t = target_named(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"bin",
"1.0.0",
);
let r = resolve(Adapter::CargoPublish).publish(&c, &t).unwrap();
assert_eq!(
cmd.calls(),
vec![
"cargo metadata --no-deps --format-version 1",
"cargo publish --registry crates-io -p bin",
]
);
assert!(clock.now_unix() > 0, "the index-wait never polled/slept");
assert_eq!(r.package, "bin");
assert_eq!(r.version, "1.0.0");
}
#[test]
fn target_without_workspace_deps_publishes_immediately() {
let cmd = FakeCmd::new().with_metadata(&metadata_two_crate("lib", "bin", "1.0.0"));
let clock = AdvancingClock::new();
let reg = SeqRegistry::new().after("lib", "1.0.0", 1);
let root = Path::new("/repo");
let c = ctx_advancing(&cmd, &clock, ®, root);
let t = target_named(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"lib",
"1.0.0",
);
resolve(Adapter::CargoPublish).publish(&c, &t).unwrap();
assert_eq!(
cmd.calls(),
vec![
"cargo metadata --no-deps --format-version 1",
"cargo publish --registry crates-io -p lib",
]
);
assert_eq!(
clock.now_unix(),
0,
"an independent crate must not index-wait (the confirm sees it on the first poll)"
);
}
#[test]
fn two_targets_never_double_publish_the_shared_dependency() {
let cmd = FakeCmd::new().with_metadata(&metadata_two_crate("lib", "bin", "1.0.0"));
let clock = AdvancingClock::new();
let reg = SeqRegistry::new()
.after("lib", "1.0.0", 2)
.after("bin", "1.0.0", 1);
let root = Path::new("/repo");
let c = ctx_advancing(&cmd, &clock, ®, root);
let lib_t = target_named(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"lib",
"1.0.0",
);
resolve(Adapter::CargoPublish).publish(&c, &lib_t).unwrap();
let bin_t = target_named(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"bin",
"1.0.0",
);
resolve(Adapter::CargoPublish).publish(&c, &bin_t).unwrap();
assert_eq!(
cmd.calls()
.iter()
.filter(|c| *c == "cargo publish --registry crates-io -p lib")
.count(),
1,
"the shared dependency was published more than once: {:?}",
cmd.calls()
);
assert_eq!(
cmd.calls()
.iter()
.filter(|c| *c == "cargo publish --registry crates-io -p bin")
.count(),
1
);
}
#[test]
fn target_skips_its_own_publish_when_already_published_on_resume() {
let digest = "c".repeat(64);
let cmd = FakeCmd::new()
.with_metadata(&metadata_single_with_target("bin", "1.0.0", "/repo/target"))
.stdout_calls_containing(
"sha256sum",
&format!("{digest} /repo/target/package/bin-1.0.0.crate"),
);
let clock = FakeClock(1);
let reg = SkipAuthRegistry::new("1.0.0", Some(&digest));
let root = Path::new("/repo");
let c = ctx_dyn(&cmd, &clock, ®, root);
let t = target_named(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"bin",
"1.0.0",
);
let r = resolve(Adapter::CargoPublish).publish(&c, &t).unwrap();
assert!(
!cmd.calls().iter().any(|call| call.contains("publish")),
"an already-published target must not re-publish: {:?}",
cmd.calls()
);
assert_eq!(r.digest.as_deref(), Some(digest.as_str()));
assert_eq!(
r.remote_url.as_deref(),
Some("https://crates.io/crates/bin/1.0.0")
);
}
#[test]
fn resume_skip_is_trusted_when_the_registry_digest_matches() {
let digest = "a".repeat(64);
let cmd = FakeCmd::new()
.with_metadata(&metadata_single_with_target(
"tool",
"1.0.0",
"/custom/tdir",
))
.stdout_calls_containing(
"sha256sum",
&format!("{digest} /custom/tdir/package/tool-1.0.0.crate"),
);
let clock = FakeClock(9);
let reg = SkipAuthRegistry::new("1.0.0", Some(&digest));
let root = Path::new("/repo");
let c = ctx_dyn(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"1.0.0",
);
let r = resolve(Adapter::CargoPublish).publish(&c, &t).unwrap();
assert_eq!(
cmd.calls(),
vec![
"cargo metadata --no-deps --format-version 1".to_string(),
"cargo package --registry crates-io -p tool --no-verify".to_string(),
"sha256sum -- /custom/tdir/package/tool-1.0.0.crate".to_string(),
]
);
assert_eq!(r.digest.as_deref(), Some(digest.as_str()));
assert_eq!(
r.remote_url.as_deref(),
Some("https://crates.io/crates/tool/1.0.0")
);
assert_eq!(r.timestamp, 9, "receipt stamps the injected clock");
}
#[test]
fn resume_skip_is_refused_when_the_registry_digest_differs() {
let intended = "a".repeat(64);
let published = "b".repeat(64);
let cmd = FakeCmd::new()
.with_metadata(&metadata_single_with_target(
"tool",
"1.0.0",
"/repo/target",
))
.stdout_calls_containing(
"sha256sum",
&format!("{intended} /repo/target/package/tool-1.0.0.crate"),
);
let clock = FakeClock(1);
let reg = SkipAuthRegistry::new("1.0.0", Some(&published));
let root = Path::new("/repo");
let c = ctx_dyn(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"1.0.0",
);
let err = resolve(Adapter::CargoPublish).publish(&c, &t).unwrap_err();
let msg = err.to_string();
match err {
AdapterError::DigestMismatch {
package,
version,
local,
remote,
} => {
assert_eq!(package, "tool");
assert_eq!(version, "1.0.0");
assert_eq!(local, intended);
assert_eq!(remote, published);
}
other => panic!("expected DigestMismatch on a differing registry digest, got {other:?}"),
}
assert!(
!cmd.calls().iter().any(|call| call.contains("publish")),
"a digest mismatch must not publish: {:?}",
cmd.calls()
);
assert!(msg.contains("tool@1.0.0"), "message: {msg}");
assert!(
msg.contains(&intended) && msg.contains(&published),
"message: {msg}"
);
assert!(msg.contains("different artifact"), "message: {msg}");
}
#[test]
fn resume_skip_fails_closed_when_the_registry_checksum_is_unavailable() {
let intended = "a".repeat(64);
let cmd = FakeCmd::new()
.with_metadata(&metadata_single_with_target(
"tool",
"1.0.0",
"/repo/target",
))
.stdout_calls_containing(
"sha256sum",
&format!("{intended} /repo/target/package/tool-1.0.0.crate"),
);
let clock = FakeClock(1);
let reg = SkipAuthRegistry::new("1.0.0", None); let root = Path::new("/repo");
let c = ctx_dyn(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"1.0.0",
);
let err = resolve(Adapter::CargoPublish).publish(&c, &t).unwrap_err();
match err {
AdapterError::RegistryUnavailable {
package, version, ..
} => {
assert_eq!(package, "tool");
assert_eq!(version, "1.0.0");
}
other => panic!("expected RegistryUnavailable on a checksum outage, got {other:?}"),
}
assert!(
!cmd.calls().iter().any(|call| call.contains("publish")),
"an unauthenticatable skip must not publish: {:?}",
cmd.calls()
);
}
#[test]
fn resume_skip_fails_closed_when_the_registry_digest_is_malformed() {
let intended = "a".repeat(64);
let cmd = FakeCmd::new()
.with_metadata(&metadata_single_with_target(
"tool",
"1.0.0",
"/repo/target",
))
.stdout_calls_containing(
"sha256sum",
&format!("{intended} /repo/target/package/tool-1.0.0.crate"),
);
let clock = FakeClock(1);
let reg = SkipAuthRegistry::new("1.0.0", Some("not-a-valid-sha256")); let root = Path::new("/repo");
let c = ctx_dyn(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"1.0.0",
);
let err = resolve(Adapter::CargoPublish).publish(&c, &t).unwrap_err();
assert!(
matches!(err, AdapterError::RegistryUnavailable { .. }),
"a malformed registry digest must fail closed as unavailable, got {err:?}"
);
assert!(!cmd.calls().iter().any(|call| call.contains("publish")));
}
#[test]
fn target_index_wait_times_out_and_never_publishes_its_crate() {
let cmd = FakeCmd::new().with_metadata(&metadata_two_crate("lib", "bin", "1.0.0"));
let clock = AdvancingClock::new();
let reg = FakeRegistry::new(); let root = Path::new("/repo");
let c = ctx_advancing(&cmd, &clock, ®, root);
let t = target_named(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"bin",
"1.0.0",
);
let err = resolve(Adapter::CargoPublish).publish(&c, &t).unwrap_err();
match err {
AdapterError::IndexTimeout {
package, version, ..
} => {
assert_eq!(package, "lib");
assert_eq!(version, "1.0.0");
}
other => panic!("expected IndexTimeout, got {other:?}"),
}
assert!(
!cmd.calls().iter().any(|call| call.contains("publish")),
"the dependent published despite a dep index-timeout: {:?}",
cmd.calls()
);
}
#[test]
fn publish_fails_loudly_when_its_own_version_never_indexes() {
let cmd = FakeCmd::new().with_metadata(&metadata_single("tool", "1.0.0"));
let clock = AdvancingClock::new();
let reg = SeqRegistry::new(); let root = Path::new("/repo");
let c = ctx_advancing(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"1.0.0",
);
let err = resolve(Adapter::CargoPublish).publish(&c, &t).unwrap_err();
let msg = err.to_string();
match err {
AdapterError::PublishNotVisible {
package, version, ..
} => {
assert_eq!(package, "tool");
assert_eq!(version, "1.0.0");
}
other => panic!("expected PublishNotVisible from the no-op, got {other:?}"),
}
assert!(
cmd.calls()
.iter()
.any(|call| call == "cargo publish --registry crates-io -p tool"),
"the publish command should still have been attempted: {:?}",
cmd.calls()
);
assert!(msg.contains("tool@1.0.0"), "message: {msg}");
assert!(msg.contains("not visible"), "message: {msg}");
assert!(
msg.contains("verify") || msg.contains("resume"),
"message: {msg}"
);
}
#[test]
fn confirm_classifies_a_flaky_but_answering_window_as_absent_not_outage() {
let cmd = FakeCmd::new().with_metadata(&metadata_single("tool", "1.0.0"));
let clock = AdvancingClock::new();
let reg = AlternatingRegistry::new();
let root = Path::new("/repo");
let c = ctx_advancing(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"1.0.0",
);
let err = resolve(Adapter::CargoPublish).publish(&c, &t).unwrap_err();
match err {
AdapterError::PublishNotVisible { package, .. } => assert_eq!(package, "tool"),
other => panic!("a flaky-but-answering window must be PublishNotVisible, got {other:?}"),
}
}
#[test]
fn publish_confirm_fails_closed_when_the_registry_goes_unreachable() {
let cmd = FakeCmd::new().with_metadata(&metadata_single("tool", "1.0.0"));
let clock = AdvancingClock::new();
let reg = AbsentThenErrRegistry::new(1);
let root = Path::new("/repo");
let c = ctx_advancing(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"1.0.0",
);
let err = resolve(Adapter::CargoPublish).publish(&c, &t).unwrap_err();
match err {
AdapterError::RegistryUnavailable {
package, version, ..
} => {
assert_eq!(package, "tool");
assert_eq!(version, "1.0.0");
}
other => panic!("expected RegistryUnavailable on a confirm-time outage, got {other:?}"),
}
assert!(cmd
.calls()
.iter()
.any(|call| call == "cargo publish --registry crates-io -p tool"));
}
#[test]
fn publish_fails_closed_when_the_registry_is_unreachable() {
let cmd = FakeCmd::new().with_metadata(&metadata_single("tool", "1.0.0"));
let clock = FakeClock(1);
let reg = FakeRegistry::erroring();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"1.0.0",
);
let err = resolve(Adapter::CargoPublish).publish(&c, &t).unwrap_err();
match err {
AdapterError::RegistryUnavailable {
package, version, ..
} => {
assert_eq!(package, "tool");
assert_eq!(version, "1.0.0");
}
other => panic!("expected RegistryUnavailable, got {other:?}"),
}
assert!(
cmd.calls().is_empty(),
"a fail-closed publish ran a command: {:?}",
cmd.calls()
);
}
#[test]
fn dep_index_wait_reports_registry_unavailable_not_a_false_index_timeout() {
let cmd = FakeCmd::new().with_metadata(&metadata_two_crate("lib", "bin", "1.0.0"));
let clock = AdvancingClock::new();
let reg = ErrForRegistry::new("lib");
let root = Path::new("/repo");
let c = ctx_advancing(&cmd, &clock, ®, root);
let t = target_named(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"bin",
"1.0.0",
);
let err = resolve(Adapter::CargoPublish).publish(&c, &t).unwrap_err();
match err {
AdapterError::RegistryUnavailable {
package, version, ..
} => {
assert_eq!(package, "lib");
assert_eq!(version, "1.0.0");
}
other => panic!("expected RegistryUnavailable, got {other:?}"),
}
assert!(
!cmd.calls().iter().any(|call| call.contains("publish")),
"the dependent published despite an unreachable registry: {:?}",
cmd.calls()
);
}
#[test]
fn target_dry_run_reports_its_own_publish_and_the_dep_wait() {
let cmd = FakeCmd::new().with_metadata(&metadata_two_crate("lib", "bin", "1.0.0"));
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target_named(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"bin",
"1.0.0",
);
let report = resolve(Adapter::CargoPublish).dry_run(&c, &t).unwrap();
let rendered: Vec<String> = report
.planned_commands
.iter()
.map(crate::protocol::release::PlannedCommand::rendered)
.collect();
assert_eq!(rendered, vec!["cargo check -p bin".to_string()]);
assert!(
report
.notes
.iter()
.any(|n| n.contains("lib@1.0.0") && n.contains("bin")),
"notes missing the dep index-wait: {:?}",
report.notes
);
assert!(
!cmd.calls().iter().any(|call| call.contains("publish")),
"dry_run executed a publish: {:?}",
cmd.calls()
);
assert!(
!cmd.calls().iter().any(|call| call.contains("package")),
"dry_run packaged a dependent that must defer: {:?}",
cmd.calls()
);
}
#[test]
fn workspace_target_package_must_be_a_publishable_member() {
let cmd = FakeCmd::new().with_metadata(&metadata_two_crate("lib", "bin", "1.0.0"));
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target_named(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"ghost",
"1.0.0",
);
let err = resolve(Adapter::CargoPublish).publish(&c, &t).unwrap_err();
match err {
AdapterError::Command { stderr, .. } => {
assert!(stderr.contains("ghost"), "message should name the package");
}
other => panic!("expected Command error, got {other:?}"),
}
assert!(!cmd.calls().iter().any(|call| call.contains("publish")));
}
#[test]
fn target_only_waits_for_crates_io_publishable_deps() {
let meta = r#"{"packages":[
{"name":"bin","version":"1.0.0","id":"bin 1.0.0","publish":null,
"dependencies":[
{"name":"lib","kind":null},
{"name":"internal","kind":null},
{"name":"helper","kind":null}
]},
{"name":"lib","version":"1.0.0","id":"lib 1.0.0","publish":null,"dependencies":[]},
{"name":"helper","version":"1.0.0","id":"helper 1.0.0","publish":[],"dependencies":[]},
{"name":"internal","version":"1.0.0","id":"internal 1.0.0","publish":["other-reg"],"dependencies":[]}
],"workspace_members":["bin 1.0.0","lib 1.0.0","helper 1.0.0","internal 1.0.0"]}"#;
let cmd = FakeCmd::new().with_metadata(meta);
let clock = AdvancingClock::new();
let reg = SeqRegistry::new()
.after("lib", "1.0.0", 1)
.after("bin", "1.0.0", 1);
let root = Path::new("/repo");
let c = ctx_advancing(&cmd, &clock, ®, root);
let t = target_named(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"bin",
"1.0.0",
);
resolve(Adapter::CargoPublish).publish(&c, &t).unwrap();
assert_eq!(
cmd.calls(),
vec![
"cargo metadata --no-deps --format-version 1",
"cargo publish --registry crates-io -p bin",
]
);
}
#[test]
fn empty_cargo_metadata_output_is_a_hard_error() {
let cmd = FakeCmd::new(); let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Rust,
Registry::CratesIo,
Adapter::CargoPublish,
"1.0.0",
);
let err = resolve(Adapter::CargoPublish).publish(&c, &t).unwrap_err();
match err {
AdapterError::Command { stderr, .. } => {
assert!(stderr.contains("no output"), "got: {stderr}");
}
other => panic!("expected Command error, got {other:?}"),
}
assert!(!cmd.calls().iter().any(|call| call.contains("publish")));
}
#[test]
fn ci_only_pypi_publish_is_unsupported_from_host() {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let t = target(
Ecosystem::Python,
Registry::Pypi,
Adapter::GhActionPypiPublish,
"1.0.0",
);
let err = resolve(Adapter::GhActionPypiPublish)
.publish(&c, &t)
.unwrap_err();
assert!(matches!(
err,
AdapterError::Unsupported {
operation: "publish",
..
}
));
assert!(cmd.calls().is_empty());
}
#[test]
fn homebrew_and_binary_have_no_build_phase() {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
for (registry, id) in [
(Registry::Homebrew, Adapter::HomebrewTap),
(Registry::GhReleases, Adapter::Manual),
] {
let t = target(Ecosystem::Binary, registry, id, "1.0.0");
let b = resolve(id).build(&c, &t).unwrap();
assert!(b.artifacts.is_empty(), "{id:?} should have no artifacts");
}
assert!(cmd.calls().is_empty(), "a no-op build shelled out");
}
#[test]
fn binary_publish_uploads_the_threaded_asset_paths() {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = ReleaseArtifacts {
assets: vec![
"dist/tool-1.0.0-x86_64.tar.gz".to_string(),
"dist/tool-1.0.0-aarch64.tar.gz".to_string(),
],
source_tarball: None,
repo_slug: None,
homebrew: None,
};
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target(
Ecosystem::Binary,
Registry::GhReleases,
Adapter::Manual,
"1.0.0",
);
resolve(Adapter::Manual).publish(&c, &t).unwrap();
assert_eq!(
cmd.calls(),
vec![
"gh release upload v1.0.0 --clobber -- dist/tool-1.0.0-x86_64.tar.gz \
dist/tool-1.0.0-aarch64.tar.gz"
]
);
}
#[test]
fn binary_publish_records_the_release_url_from_the_threaded_slug() {
let cmd = FakeCmd::new();
let clock = FakeClock(7);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = ReleaseArtifacts {
assets: vec!["dist/tool-1.0.0-x86_64.tar.gz".to_string()],
source_tarball: None,
repo_slug: Some("o/r".to_string()),
homebrew: None,
};
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target(
Ecosystem::Binary,
Registry::GhReleases,
Adapter::Manual,
"1.0.0",
);
let r = resolve(Adapter::Manual).publish(&c, &t).unwrap();
assert_eq!(
cmd.calls(),
vec!["gh release upload v1.0.0 --repo o/r --clobber -- dist/tool-1.0.0-x86_64.tar.gz"]
);
assert_eq!(
r.remote_url.as_deref(),
Some("https://github.com/o/r/releases/tag/v1.0.0")
);
assert_eq!(r.digest, None);
assert_eq!(r.timestamp, 7, "receipt stamps the injected clock");
}
#[test]
fn binary_publish_records_no_url_without_a_slug() {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = ReleaseArtifacts {
assets: vec!["dist/tool-1.0.0-x86_64.tar.gz".to_string()],
source_tarball: None,
repo_slug: None,
homebrew: None,
};
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target(
Ecosystem::Binary,
Registry::GhReleases,
Adapter::Manual,
"1.0.0",
);
let r = resolve(Adapter::Manual).publish(&c, &t).unwrap();
assert_eq!(r.remote_url, None);
assert_eq!(r.digest, None);
}
#[test]
fn homebrew_publish_reads_the_threaded_tarball_url_and_sha256() {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = ReleaseArtifacts {
assets: vec![],
source_tarball: Some(SourceTarball {
url: "https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz".to_string(),
sha256: Some("deadbeef".to_string()),
}),
repo_slug: Some("o/r".to_string()),
homebrew: None,
};
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"1.0.0",
);
resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap();
assert_eq!(
cmd.calls(),
vec![
"brew bump-formula-pr --url \
https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz --sha256 deadbeef -- tool"
]
);
}
#[test]
fn homebrew_core_publish_omits_sha256_when_not_yet_computed() {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = ReleaseArtifacts {
assets: vec![],
source_tarball: Some(SourceTarball {
url: "https://github.com/o/r/archive/refs/tags/v2.0.0.tar.gz".to_string(),
sha256: None,
}),
repo_slug: Some("o/r".to_string()),
homebrew: None,
};
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewCore,
"2.0.0",
);
resolve(Adapter::HomebrewCore).publish(&c, &t).unwrap();
assert_eq!(
cmd.calls(),
vec![
"brew bump-formula-pr --no-fork --url \
https://github.com/o/r/archive/refs/tags/v2.0.0.tar.gz -- tool"
]
);
}
fn homebrew_artifacts(
tap: &str,
url: &str,
sha256: Option<&str>,
license: Option<&str>,
) -> ReleaseArtifacts {
ReleaseArtifacts {
assets: vec![],
source_tarball: Some(SourceTarball {
url: url.to_string(),
sha256: sha256.map(str::to_string),
}),
repo_slug: Some("o/r".to_string()),
homebrew: Some(HomebrewFormula {
tap: Some(tap.to_string()),
license: license.map(str::to_string),
}),
}
}
fn read_created_formula(name: &str, version: &str) -> (std::path::PathBuf, String) {
let prefix = format!("ossctl-homebrew-{name}-{version}-");
let dir = std::fs::read_dir(std::env::temp_dir())
.unwrap()
.filter_map(Result::ok)
.map(|e| e.path())
.find(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(&prefix))
})
.expect("the create path made a scratch checkout");
let formula = std::fs::read_to_string(dir.join(format!("Formula/{name}.rb")))
.expect("the create path wrote the formula file");
(dir, formula)
}
fn rendered_formula(name: &str, url: &str, sha256: &str, license: Option<&str>) -> String {
super::homebrew::render_formula(name, Some("o/r"), url, Some(sha256), license)
}
#[test]
fn homebrew_tap_direct_writes_when_the_formula_already_exists() {
let url = "https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz";
let sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let cmd = FakeCmd::new()
.seed_clone(
"Formula/hbwrite.rb",
"# Generated by ossctl; do not edit by hand (template-version: 1)\n\
class Hbwrite < Formula\n # old\nend\n",
);
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts("o/homebrew-r", url, Some(sha), Some("MIT"));
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target_named(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"hbwrite",
"1.0.0",
);
let r = resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap();
let calls = cmd.calls();
assert_eq!(
calls[0],
"gh api --silent repos/o/homebrew-r/contents/Formula/hbwrite.rb"
);
assert!(
calls
.iter()
.any(|c| c.starts_with("gh repo clone o/homebrew-r ")),
"expected a tap clone: {calls:?}"
);
assert!(
calls.iter().any(|c| c.contains("add Formula/hbwrite.rb")),
"expected the updated formula to be staged: {calls:?}"
);
assert!(
calls.iter().any(|c| c.contains("commit -m hbwrite 1.0.0")),
"expected a commit: {calls:?}"
);
assert!(
calls.iter().any(|c| c.ends_with("push origin HEAD")),
"expected a direct push to the default branch: {calls:?}"
);
assert!(
!calls.iter().any(|c| c.contains("bump-formula-pr")),
"the tap-write path must not call bump-formula-pr: {calls:?}"
);
assert!(
!calls.iter().any(|c| c.contains("pr create")),
"the tap-write path must not open a PR: {calls:?}"
);
assert_eq!(r.digest.as_deref(), Some(sha));
assert_eq!(
r.remote_url.as_deref(),
Some("https://github.com/o/homebrew-r/blob/HEAD/Formula/hbwrite.rb")
);
let (workdir, formula) = read_created_formula("hbwrite", "1.0.0");
assert!(formula.contains(&format!("url \"{url}\"")), "{formula}");
assert!(
formula.contains(
"sha256 \"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\""
),
"{formula}"
);
let _ = std::fs::remove_dir_all(&workdir);
}
#[test]
fn homebrew_tap_direct_write_is_a_noop_when_the_formula_is_unchanged() {
let url = "https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz";
let sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let current = rendered_formula("hbnoop", url, sha, Some("MIT"));
let cmd = FakeCmd::new().seed_clone("Formula/hbnoop.rb", ¤t);
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts("o/homebrew-r", url, Some(sha), Some("MIT"));
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target_named(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"hbnoop",
"1.0.0",
);
let r = resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap();
let calls = cmd.calls();
assert!(
calls.iter().any(|c| c.starts_with("gh repo clone")),
"expected the tap clone: {calls:?}"
);
assert!(
!calls.iter().any(|c| c.contains("commit")),
"an unchanged formula must not create an empty commit: {calls:?}"
);
assert!(
!calls.iter().any(|c| c.ends_with("push origin HEAD")),
"an unchanged formula must not push: {calls:?}"
);
assert_eq!(r.digest.as_deref(), Some(sha));
assert_eq!(
r.remote_url.as_deref(),
Some("https://github.com/o/homebrew-r/blob/HEAD/Formula/hbnoop.rb")
);
let (workdir, _) = read_created_formula("hbnoop", "1.0.0");
let _ = std::fs::remove_dir_all(&workdir);
}
fn hand_maintained_formula(name: &str, old_url: &str, old_sha: &str) -> String {
let class = {
let mut c = name[..1].to_uppercase();
c.push_str(&name[1..]);
c
};
format!(
"class {class} < Formula\n\
\x20 desc \"hand tuned {name}\"\n\
\x20 homepage \"https://example.com/{name}\"\n\
\x20 url \"{old_url}\"\n\
\x20 sha256 \"{old_sha}\"\n\
\x20 license \"MIT\"\n\
\n\
\x20 depends_on \"openssl@3\"\n\
\n\
\x20 def install\n\
\x20 system \"make\", \"install\"\n\
\x20 end\n\
\n\
\x20 test do\n\
\x20 system bin/\"{name}\", \"--version\"\n\
\x20 end\n\
end\n"
)
}
#[test]
fn homebrew_render_formula_carries_the_ownership_marker() {
let formula = rendered_formula(
"markme",
"https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz",
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
Some("MIT"),
);
assert!(
formula.starts_with("# Generated by ossctl; do not edit by hand (template-version: 1)\n"),
"the marker must be the first line: {formula}"
);
assert!(
formula.contains("class Markme < Formula"),
"the class must still follow the marker: {formula}"
);
}
#[test]
fn homebrew_tap_write_surgically_edits_a_hand_maintained_formula() {
let old_url = "https://github.com/o/r/archive/refs/tags/v0.9.0.tar.gz";
let old_sha = "0000000000000000000000000000000000000000000000000000000000000000";
let new_url = "https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz";
let new_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let current = hand_maintained_formula("hbhand", old_url, old_sha);
let cmd = FakeCmd::new().seed_clone("Formula/hbhand.rb", ¤t);
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts("o/homebrew-r", new_url, Some(new_sha), Some("MIT"));
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target_named(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"hbhand",
"1.0.0",
);
let r = resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap();
let calls = cmd.calls();
assert!(
calls.iter().any(|c| c.contains("commit -m hbhand 1.0.0")),
"expected a commit for the surgical edit: {calls:?}"
);
assert!(
calls.iter().any(|c| c.ends_with("push origin HEAD")),
"expected the surgical edit to be pushed: {calls:?}"
);
assert_eq!(r.digest.as_deref(), Some(new_sha));
let (workdir, written) = read_created_formula("hbhand", "1.0.0");
assert!(written.contains(&format!("url \"{new_url}\"")), "{written}");
assert!(
written.contains(&format!("sha256 \"{new_sha}\"")),
"{written}"
);
assert!(
!written.contains(old_url),
"old url must be replaced: {written}"
);
assert!(
!written.contains(old_sha),
"old sha must be replaced: {written}"
);
assert!(written.contains("depends_on \"openssl@3\""), "{written}");
assert!(
written.contains("system \"make\", \"install\""),
"{written}"
);
assert!(written.contains("desc \"hand tuned hbhand\""), "{written}");
assert!(
!written.contains("# Generated by ossctl"),
"surgical edit must not add the ownership marker: {written}"
);
let _ = std::fs::remove_dir_all(&workdir);
}
#[test]
fn homebrew_tap_write_surgical_edit_is_a_noop_at_the_target_version() {
let url = "https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz";
let sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let current = hand_maintained_formula("hbhnoop", url, sha);
let cmd = FakeCmd::new().seed_clone("Formula/hbhnoop.rb", ¤t);
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts("o/homebrew-r", url, Some(sha), Some("MIT"));
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target_named(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"hbhnoop",
"1.0.0",
);
let r = resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap();
let calls = cmd.calls();
assert!(
!calls.iter().any(|c| c.contains("commit")),
"an unchanged surgical edit must not commit: {calls:?}"
);
assert!(
!calls.iter().any(|c| c.ends_with("push origin HEAD")),
"an unchanged surgical edit must not push: {calls:?}"
);
assert_eq!(r.digest.as_deref(), Some(sha));
let (workdir, _) = read_created_formula("hbhnoop", "1.0.0");
let _ = std::fs::remove_dir_all(&workdir);
}
#[test]
fn homebrew_tap_write_refuses_an_unmarked_formula_with_no_url_stanza() {
let cmd = FakeCmd::new().seed_clone(
"Formula/hbweird.rb",
"class Hbweird < Formula\n # hand-maintained, no url stanza here\nend\n",
);
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts(
"o/homebrew-r",
"https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz",
Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
Some("MIT"),
);
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target_named(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"hbweird",
"1.0.0",
);
let err = resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap_err();
assert!(
matches!(&err, AdapterError::Command { stderr, .. }
if stderr.contains("ossctl ownership marker") && stderr.contains("found 0 `url`")),
"expected a fail-closed refusal naming the missing marker + zero url lines: {err:?}"
);
let calls = cmd.calls();
assert!(
!calls
.iter()
.any(|c| c.contains("commit") || c.ends_with("push origin HEAD")),
"a refused hand-maintained formula must not be committed/pushed: {calls:?}"
);
}
#[test]
fn homebrew_marker_is_recognised_only_on_the_first_line() {
let new_url = "https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz";
let new_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let current = "class Hbfake < Formula\n\
\x20 desc \"hand tuned hbfake\"\n\
\x20 # historical note: # Generated by ossctl; do not edit by hand (template-version: 1)\n\
\x20 url \"https://github.com/o/r/archive/refs/tags/v0.9.0.tar.gz\"\n\
\x20 sha256 \"0000000000000000000000000000000000000000000000000000000000000000\"\n\
\x20 depends_on \"openssl@3\"\n\
end\n";
let cmd = FakeCmd::new().seed_clone("Formula/hbfake.rb", current);
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts("o/homebrew-r", new_url, Some(new_sha), Some("MIT"));
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target_named(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"hbfake",
"1.0.0",
);
resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap();
let (workdir, written) = read_created_formula("hbfake", "1.0.0");
assert!(
written.contains("depends_on \"openssl@3\""),
"a quoted-but-not-first-line marker must not trigger a clobbering regenerate: {written}"
);
assert!(
!written.starts_with("# Generated by ossctl"),
"surgical path must not prepend the marker: {written}"
);
assert!(written.contains(&format!("url \"{new_url}\"")), "{written}");
let _ = std::fs::remove_dir_all(&workdir);
}
#[test]
fn homebrew_surgical_edit_preserves_trailing_options_comments_and_crlf() {
let new_url = "https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz";
let new_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let current = "class Hbcrlf < Formula\r\n\
\x20 desc \"crlf tool\"\r\n\
\x20 url \"https://github.com/o/r/archive/refs/tags/v0.9.0.tar.gz\" # primary mirror\r\n\
\x20 sha256 \"0000000000000000000000000000000000000000000000000000000000000000\" # pinned\r\n\
\x20 depends_on \"openssl@3\"\r\n\
end\r\n";
let cmd = FakeCmd::new().seed_clone("Formula/hbcrlf.rb", current);
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts("o/homebrew-r", new_url, Some(new_sha), Some("MIT"));
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target_named(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"hbcrlf",
"1.0.0",
);
resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap();
let (workdir, written) = read_created_formula("hbcrlf", "1.0.0");
assert!(
written.contains(&format!("url \"{new_url}\" # primary mirror")),
"trailing url comment must be preserved: {written:?}"
);
assert!(
written.contains(&format!("sha256 \"{new_sha}\" # pinned")),
"trailing sha256 comment must be preserved: {written:?}"
);
assert!(
written.contains("\r\n") && !written.contains("\n\n"),
"CRLF line endings must be preserved: {written:?}"
);
assert!(written.contains("depends_on \"openssl@3\""), "{written:?}");
let _ = std::fs::remove_dir_all(&workdir);
}
#[test]
fn homebrew_surgical_edit_refuses_a_resource_block_with_its_own_url_sha() {
let current = "class Hbres < Formula\n\
\x20 url \"https://github.com/o/r/archive/refs/tags/v0.9.0.tar.gz\"\n\
\x20 sha256 \"0000000000000000000000000000000000000000000000000000000000000000\"\n\
\n\
\x20 resource \"extra\" do\n\
\x20 url \"https://example.com/extra-0.9.tar.gz\"\n\
\x20 sha256 \"1111111111111111111111111111111111111111111111111111111111111111\"\n\
\x20 end\n\
end\n";
let cmd = FakeCmd::new().seed_clone("Formula/hbres.rb", current);
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts(
"o/homebrew-r",
"https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz",
Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
Some("MIT"),
);
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target_named(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"hbres",
"1.0.0",
);
let err = resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap_err();
assert!(
matches!(&err, AdapterError::Command { stderr, .. }
if stderr.contains("found 2 `url` and 2 `sha256`")),
"expected a fail-closed refusal naming the ambiguous resource-block pairs: {err:?}"
);
let calls = cmd.calls();
assert!(
!calls
.iter()
.any(|c| c.contains("commit") || c.ends_with("push origin HEAD")),
"an ambiguous resource-block formula must not be committed/pushed: {calls:?}"
);
}
#[test]
fn homebrew_tap_direct_write_fails_closed_without_a_verified_sha256() {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts(
"o/homebrew-r",
"https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz",
None,
Some("MIT"),
);
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"1.0.0",
);
let err = resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap_err();
assert!(
matches!(&err, AdapterError::Command { stderr, .. } if stderr.contains("verified sha256")),
"expected a fail-closed error naming the missing verified sha256: {err:?}"
);
let calls = cmd.calls();
assert!(
!calls.iter().any(|c| c.contains("clone")
|| c.contains("commit")
|| c.ends_with("push origin HEAD")),
"must not clone/commit/push without a verified sha256: {calls:?}"
);
}
#[test]
fn homebrew_tap_direct_write_fails_closed_on_a_malformed_sha256() {
for bad in [
"",
"deadbeef",
"not-hex-but-64-chars-long-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
] {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts(
"o/homebrew-r",
"https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz",
Some(bad),
Some("MIT"),
);
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"1.0.0",
);
let err = resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap_err();
assert!(
matches!(&err, AdapterError::Command { stderr, .. } if stderr.contains("verified sha256")),
"malformed sha {bad:?} should fail closed: {err:?}"
);
assert!(
!cmd.calls().iter().any(|c| c.contains("clone")),
"malformed sha {bad:?} must not clone: {:?}",
cmd.calls()
);
}
}
#[test]
fn homebrew_tap_direct_write_fails_closed_when_the_formula_vanished_after_clone() {
let cmd = FakeCmd::new(); let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts(
"o/homebrew-r",
"https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz",
Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
Some("MIT"),
);
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"1.0.0",
);
let err = resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap_err();
assert!(
matches!(&err, AdapterError::Command { stderr, .. } if stderr.contains("review gate")),
"expected a fail-closed error citing the review gate: {err:?}"
);
let calls = cmd.calls();
assert!(
calls.iter().any(|c| c.starts_with("gh repo clone")),
"should have cloned before discovering the missing formula: {calls:?}"
);
assert!(
!calls
.iter()
.any(|c| c.contains("commit") || c.ends_with("push origin HEAD")),
"must not commit/push a synthesized formula: {calls:?}"
);
}
#[test]
#[cfg(unix)]
fn homebrew_tap_direct_write_refuses_a_symlink_formula() {
let cmd = FakeCmd::new().seed_clone_symlink("Formula/hbsym.rb");
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts(
"o/homebrew-r",
"https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz",
Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
Some("MIT"),
);
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target_named(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"hbsym",
"1.0.0",
);
let err = resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap_err();
assert!(
matches!(&err, AdapterError::Filesystem { source, .. } if source.contains("regular file")),
"expected a fail-closed error refusing the symlink: {err:?}"
);
assert!(
!cmd.calls()
.iter()
.any(|c| c.contains("commit") || c.ends_with("push origin HEAD")),
"must not commit/push over a symlink: {:?}",
cmd.calls()
);
let (workdir, _) = read_created_formula("hbsym", "1.0.0");
let _ = std::fs::remove_dir_all(&workdir);
}
#[test]
fn homebrew_tap_direct_write_rejects_a_traversal_package_name() {
let cmd = FakeCmd::new().seed_clone("Formula/x.rb", "x");
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts(
"o/homebrew-r",
"https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz",
Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
Some("MIT"),
);
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target_named(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"../evil",
"1.0.0",
);
let err = resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap_err();
assert!(
matches!(&err, AdapterError::Filesystem { source, .. } if source.contains("package name")),
"expected a rejected package name: {err:?}"
);
}
#[test]
fn homebrew_tap_creates_the_initial_formula_when_absent() {
let cmd = FakeCmd::new().fail_calls_containing("contents/", 1, "HTTP 404: Not Found");
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts(
"o/homebrew-r",
"https://github.com/o/r/archive/refs/tags/v1.2.3.tar.gz",
Some("cafef00d"),
Some("MIT"),
);
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target_named(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"hbcreate",
"1.2.3",
);
resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap();
let calls = cmd.calls();
assert_eq!(
calls[0],
"gh api --silent repos/o/homebrew-r/contents/Formula/hbcreate.rb"
);
assert!(
calls
.iter()
.any(|c| c.contains("gh repo clone o/homebrew-r")),
"expected a tap clone: {calls:?}"
);
assert!(
calls
.iter()
.any(|c| c.contains("checkout -b ossctl-homebrew-hbcreate-1.2.3")),
"expected a create branch: {calls:?}"
);
assert!(
calls.iter().any(|c| c.contains("add Formula/hbcreate.rb")),
"expected the new formula to be staged: {calls:?}"
);
assert!(
calls
.iter()
.any(|c| c.contains("push --set-upstream origin ossctl-homebrew-hbcreate-1.2.3")),
"expected the branch to be pushed: {calls:?}"
);
assert!(
calls
.iter()
.any(|c| c.contains("gh pr create --repo o/homebrew-r")),
"expected a PR to be opened: {calls:?}"
);
assert!(
!calls.iter().any(|c| c.contains("bump-formula-pr")),
"the create path must not bump: {calls:?}"
);
let (workdir, formula) = read_created_formula("hbcreate", "1.2.3");
assert!(formula.contains("class Hbcreate < Formula"), "{formula}");
assert!(
formula.contains("url \"https://github.com/o/r/archive/refs/tags/v1.2.3.tar.gz\""),
"{formula}"
);
assert!(formula.contains("sha256 \"cafef00d\""), "{formula}");
assert!(formula.contains("license \"MIT\""), "{formula}");
assert!(
formula.contains("homepage \"https://github.com/o/r\""),
"{formula}"
);
assert!(
formula.contains("system \"cargo\", \"install\""),
"{formula}"
);
assert!(
calls
.iter()
.any(|c| c.contains("gh pr create") && !c.contains("--draft")),
"sha256 present should open a ready PR: {calls:?}"
);
let _ = std::fs::remove_dir_all(&workdir);
}
#[test]
fn homebrew_create_records_the_pr_url_as_remote_url() {
let cmd = FakeCmd::new()
.fail_calls_containing("contents/", 1, "HTTP 404")
.stdout_calls_containing(
"pr create",
"Warning: 1 uncommitted change\nhttps://github.com/o/homebrew-r/pull/7\n",
);
let clock = FakeClock(9);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts(
"o/homebrew-r",
"https://github.com/o/r/archive/refs/tags/v4.5.6.tar.gz",
Some("beefcafe"),
Some("MIT"),
);
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target_named(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"hburl",
"4.5.6",
);
let r = resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap();
assert_eq!(
r.remote_url.as_deref(),
Some("https://github.com/o/homebrew-r/pull/7")
);
assert_eq!(r.timestamp, 9);
let (workdir, _) = read_created_formula("hburl", "4.5.6");
let _ = std::fs::remove_dir_all(&workdir);
}
#[test]
fn homebrew_create_generates_a_sha256_placeholder_when_absent() {
let cmd = FakeCmd::new().fail_calls_containing("contents/", 1, "404");
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts(
"o/homebrew-r",
"https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz",
None,
None,
);
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target_named(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"hbnosha",
"1.0.0",
);
resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap();
let (workdir, formula) = read_created_formula("hbnosha", "1.0.0");
assert!(formula.contains("# TODO: sha256"), "{formula}");
assert!(!formula.contains("license \""), "{formula}");
assert!(
cmd.calls()
.iter()
.any(|c| c.contains("gh pr create") && c.contains("--draft")),
"absent sha256 should open a draft PR: {:?}",
cmd.calls()
);
let _ = std::fs::remove_dir_all(&workdir);
}
#[test]
fn homebrew_dry_run_reports_the_chosen_path() {
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts(
"o/homebrew-r",
"https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz",
Some("deadbeef"),
Some("MIT"),
);
let create_cmd = FakeCmd::new().fail_calls_containing("contents/", 1, "404");
let cc = ctx_with(&create_cmd, &clock, ®, root, &artifacts);
let t = target(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"1.0.0",
);
let create = resolve(Adapter::HomebrewTap).dry_run(&cc, &t).unwrap();
assert!(
create.notes.iter().any(|n| n.contains("create path")),
"{:?}",
create.notes
);
assert!(
create
.planned_commands
.iter()
.any(|c| c.rendered().contains("gh pr create")),
"create dry-run should preview the PR: {:?}",
create.planned_commands
);
let write_cmd = FakeCmd::new();
let wc = ctx_with(&write_cmd, &clock, ®, root, &artifacts);
let write = resolve(Adapter::HomebrewTap).dry_run(&wc, &t).unwrap();
assert!(
write.notes.iter().any(|n| n.contains("tap-write path")),
"{:?}",
write.notes
);
let rendered: Vec<String> = write
.planned_commands
.iter()
.map(crate::protocol::release::PlannedCommand::rendered)
.collect();
assert!(
rendered
.iter()
.any(|c| c.starts_with("gh repo clone o/homebrew-r ")),
"{rendered:?}"
);
assert!(
rendered.iter().any(|c| c.ends_with("push origin HEAD")),
"{rendered:?}"
);
assert!(
!rendered
.iter()
.any(|c| c.contains("bump-formula-pr") || c.contains("pr create")),
"{rendered:?}"
);
}
#[test]
fn homebrew_dry_run_previews_bump_pr_without_a_configured_tap() {
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = ReleaseArtifacts {
assets: vec![],
source_tarball: Some(SourceTarball {
url: "https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz".to_string(),
sha256: Some("deadbeef".to_string()),
}),
repo_slug: Some("o/r".to_string()),
homebrew: None,
};
let cmd = FakeCmd::new();
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewCore,
"1.0.0",
);
let bump = resolve(Adapter::HomebrewCore).dry_run(&c, &t).unwrap();
assert!(
bump.notes.iter().any(|n| n.contains("bump-PR path")),
"{:?}",
bump.notes
);
assert_eq!(bump.planned_commands.len(), 1);
assert!(bump.planned_commands[0]
.rendered()
.starts_with("brew bump-formula-pr"));
assert!(cmd.calls().is_empty(), "{:?}", cmd.calls());
}
#[test]
fn homebrew_probe_error_is_not_treated_as_absent() {
let cmd = FakeCmd::new().fail_calls_containing("contents/", 1, "HTTP 403: rate limit exceeded");
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts(
"o/homebrew-r",
"https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz",
Some("deadbeef"),
Some("MIT"),
);
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"1.0.0",
);
let err = resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap_err();
assert!(matches!(err, AdapterError::Command { .. }), "got {err:?}");
assert_eq!(
cmd.calls().len(),
1,
"must abort after the probe: {:?}",
cmd.calls()
);
}
#[test]
fn homebrew_create_class_name_handles_a_leading_digit() {
let cmd = FakeCmd::new().fail_calls_containing("contents/", 1, "404");
let clock = FakeClock(1);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let artifacts = homebrew_artifacts(
"o/homebrew-r",
"https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz",
Some("deadbeef"),
None,
);
let c = ctx_with(&cmd, &clock, ®, root, &artifacts);
let t = target_named(
Ecosystem::Binary,
Registry::Homebrew,
Adapter::HomebrewTap,
"3d-tool",
"1.0.0",
);
resolve(Adapter::HomebrewTap).publish(&c, &t).unwrap();
let (workdir, formula) = read_created_formula("3d-tool", "1.0.0");
assert!(formula.contains("class X3dTool < Formula"), "{formula}");
let _ = std::fs::remove_dir_all(&workdir);
}
#[test]
fn homebrew_render_formula_neutralizes_ruby_interpolation() {
let malicious = "MIT #{system('touch /tmp/pwned')}";
let formula = super::homebrew::render_formula(
"tool",
Some("o/r"),
"https://github.com/o/r/archive/refs/tags/v1.0.0.tar.gz",
Some("deadbeef"),
Some(malicious),
);
assert!(
!formula.contains("MIT #{"),
"unescaped Ruby interpolation leaked into the formula: {formula}"
);
assert!(
formula.contains("MIT \\#{system('touch /tmp/pwned')}"),
"expected the `#` escaped: {formula}"
);
}
#[test]
fn classify_unknown_when_lookup_absent() {
let r = receipt(Ecosystem::Rust, "1.0.0", None);
assert_eq!(classify_receipt(&r, None), VerifyOutcome::Unknown);
}
#[test]
fn classify_missing_when_version_absent() {
let r = receipt(Ecosystem::Rust, "1.0.0", None);
let obs = RemoteObservation {
published_versions: vec!["0.9.0".to_string()],
remote_digest: None,
};
assert_eq!(classify_receipt(&r, Some(&obs)), VerifyOutcome::Missing);
}
#[test]
fn classify_matches_when_version_present() {
let r = receipt(Ecosystem::Rust, "1.0.0", None);
let obs = RemoteObservation {
published_versions: vec!["0.9.0".to_string(), "1.0.0".to_string()],
remote_digest: None,
};
assert_eq!(classify_receipt(&r, Some(&obs)), VerifyOutcome::Matches);
}
#[test]
fn classify_matches_when_digests_agree() {
let r = receipt(Ecosystem::Rust, "1.0.0", Some("sha256:aaa"));
let obs = RemoteObservation {
published_versions: vec!["1.0.0".to_string()],
remote_digest: Some("sha256:aaa".to_string()),
};
assert_eq!(classify_receipt(&r, Some(&obs)), VerifyOutcome::Matches);
}
#[test]
fn classify_conflicts_on_digest_mismatch() {
let r = receipt(Ecosystem::Rust, "1.0.0", Some("sha256:aaa"));
let obs = RemoteObservation {
published_versions: vec!["1.0.0".to_string()],
remote_digest: Some("sha256:bbb".to_string()),
};
assert_eq!(classify_receipt(&r, Some(&obs)), VerifyOutcome::Conflicts);
}
#[test]
fn verify_matches_via_registry() {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::new().with("rust", "tool", &["1.0.0"]);
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let r = receipt(Ecosystem::Rust, "1.0.0", None);
let out = resolve(Adapter::CargoPublish).verify(&c, &r).unwrap();
assert_eq!(out, VerifyOutcome::Matches);
}
#[test]
fn verify_missing_via_registry() {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::new().with("rust", "tool", &["0.1.0"]);
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let r = receipt(Ecosystem::Rust, "1.0.0", None);
let out = resolve(Adapter::CargoPublish).verify(&c, &r).unwrap();
assert_eq!(out, VerifyOutcome::Missing);
}
#[test]
fn verify_unknown_on_registry_outage() {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::erroring();
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let r = receipt(Ecosystem::Rust, "1.0.0", None);
let out = resolve(Adapter::CargoPublish).verify(&c, &r).unwrap();
assert_eq!(out, VerifyOutcome::Unknown);
}
#[test]
fn homebrew_and_binary_verify_is_always_unknown() {
let cmd = FakeCmd::new();
let clock = FakeClock(1);
let reg = FakeRegistry::new().with("binary", "tool", &["1.0.0"]);
let root = Path::new("/repo");
let c = ctx(&cmd, &clock, ®, root);
let r = receipt(Ecosystem::Binary, "1.0.0", None);
for id in [Adapter::HomebrewTap, Adapter::HomebrewCore, Adapter::Manual] {
assert_eq!(
resolve(id).verify(&c, &r).unwrap(),
VerifyOutcome::Unknown,
"{id:?} must report Unknown"
);
}
}
#[test]
fn verify_outcome_as_str_matches_serde() {
for v in [
VerifyOutcome::Matches,
VerifyOutcome::Conflicts,
VerifyOutcome::Missing,
VerifyOutcome::Unknown,
] {
assert_eq!(
serde_json::to_value(v).unwrap(),
serde_json::Value::String(v.as_str().to_string()),
"as_str() drifted from serde for {v:?}"
);
}
}
#[test]
fn ci_delegation_matches_the_unsupported_publishers() {
let delegated = [
Adapter::CargoDist,
Adapter::ReleasePlease,
Adapter::GhActionPypiPublish,
];
let all = [
Adapter::CargoPublish,
Adapter::CargoDist,
Adapter::ReleasePlease,
Adapter::Changesets,
Adapter::NpmPublish,
Adapter::GhActionPypiPublish,
Adapter::Twine,
Adapter::Goreleaser,
Adapter::HomebrewTap,
Adapter::HomebrewCore,
Adapter::Manual,
];
for id in all {
assert_eq!(
resolve(id).is_ci_delegated(),
delegated.contains(&id),
"is_ci_delegated() is wrong for {id:?}"
);
}
let runner = FakeCmd::new();
let clock = FakeClock(0);
let reg = FakeRegistry::new();
let root = Path::new("/repo");
let c = ctx(&runner, &clock, ®, root);
for id in delegated {
let t = target(Ecosystem::Rust, Registry::GhReleases, id, "1.0.0");
assert!(
matches!(
resolve(id).publish(&c, &t),
Err(AdapterError::Unsupported {
operation: "publish",
..
})
),
"delegated adapter {id:?} must publish() -> Unsupported"
);
}
}
#[test]
fn only_cargo_dist_owns_the_github_release_and_it_is_a_subset_of_ci_delegation() {
let all = [
Adapter::CargoPublish,
Adapter::CargoDist,
Adapter::ReleasePlease,
Adapter::Changesets,
Adapter::NpmPublish,
Adapter::GhActionPypiPublish,
Adapter::Twine,
Adapter::Goreleaser,
Adapter::HomebrewTap,
Adapter::HomebrewCore,
Adapter::Manual,
];
for id in all {
let owns = resolve(id).ci_owns_github_release();
assert_eq!(
owns,
id == Adapter::CargoDist,
"ci_owns_github_release() is wrong for {id:?}"
);
if owns {
assert!(
resolve(id).is_ci_delegated(),
"{id:?} owns the Release but is not CI-delegated"
);
}
}
assert!(!resolve(Adapter::GhActionPypiPublish).ci_owns_github_release());
assert!(!resolve(Adapter::ReleasePlease).ci_owns_github_release());
}