use std::collections::HashSet;
use callisto_model::{
CommandRunner, CratePublish, DepKind, NpmMainPublish, PackageId, PublishPlan, PublishTarget, PypiPublish,
RegistryKey, ReleaseEntry, SCHEMA_VERSION,
};
use callisto_vcs::GitDataSource;
use crate::error::GraphError;
use crate::resolver::DependencyResolver;
use crate::toposort::toposort_impl;
use crate::Workspace;
const CASCADE_ORDERING_KINDS: &[DepKind] = &[DepKind::Runtime, DepKind::Build, DepKind::Optional];
const PUBLISH_ORDERING_KINDS: &[DepKind] = &[DepKind::Runtime, DepKind::Build, DepKind::Optional, DepKind::Dev];
fn publish_order<D: DependencyResolver + ?Sized>(
resolver: &D,
subset: &HashSet<PackageId>,
) -> Result<Vec<PackageId>, GraphError> {
let all_pkg_ids: Vec<PackageId> = resolver.packages().map(|p| p.id.clone()).collect();
let edges_of = |id: &PackageId| -> Vec<(PackageId, DepKind)> {
resolver.dependencies_of(id).map(|e| (e.to.clone(), e.kind)).collect()
};
match toposort_impl(subset, &all_pkg_ids, PUBLISH_ORDERING_KINDS, edges_of) {
Ok(order) => Ok(order),
Err(GraphError::Cycle { .. }) => toposort_impl(subset, &all_pkg_ids, CASCADE_ORDERING_KINDS, edges_of),
Err(e) => Err(e),
}
}
#[derive(Clone, Debug, Default)]
pub struct PublishOptions {
pub only: Vec<String>,
}
fn validate_npm_registry_url(
url: &str,
package: &PackageId,
registries: &std::collections::BTreeMap<RegistryKey, crate::config::RegistryConfig>,
) -> Result<(), GraphError> {
let is_approved = url.starts_with("https://")
&& registries
.values()
.any(|cfg| cfg.kind == Ecosystem::Npm && cfg.url.as_deref() == Some(url));
if is_approved {
Ok(())
} else {
Err(GraphError::UntrustedNpmRegistry {
package: package.clone(),
url: url.to_string(),
})
}
}
fn resolve_changelog_section(
ws_root: &std::path::Path,
changelog_rel_path: &std::path::Path,
pkg_id: &callisto_model::PackageId,
ver: &callisto_model::Version,
diagnostics: &mut Vec<callisto_model::Diagnostic>,
) -> Option<String> {
let full_path = ws_root.join(changelog_rel_path);
let content = match std::fs::read_to_string(&full_path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
diagnostics.push(callisto_model::Diagnostic {
code: callisto_model::DiagnosticCode::ChangelogSectionNotFound,
severity: callisto_model::DiagnosticSeverity::Warning,
message: format!(
"no changelog file found at `{}` for package `{}`",
changelog_rel_path.display(),
pkg_id.display_name()
),
package: Some(pkg_id.clone()),
path: Some(changelog_rel_path.to_path_buf()),
escalated_by: None,
governed_by: None,
});
return None;
}
Err(e) => {
diagnostics.push(callisto_model::Diagnostic {
code: callisto_model::DiagnosticCode::ChangelogReadError,
severity: callisto_model::DiagnosticSeverity::Warning,
message: format!(
"could not read changelog at `{}` for package `{}`: {e}",
changelog_rel_path.display(),
pkg_id.display_name()
),
package: Some(pkg_id.clone()),
path: Some(changelog_rel_path.to_path_buf()),
escalated_by: None,
governed_by: None,
});
return None;
}
};
match callisto_changelog::extract_section(&content, ver) {
Some(section) => Some(section.to_string()),
None => {
diagnostics.push(callisto_model::Diagnostic {
code: callisto_model::DiagnosticCode::ChangelogSectionNotFound,
severity: callisto_model::DiagnosticSeverity::Warning,
message: format!(
"no `## {}` section found in `{}` for package `{}`",
ver.render(),
changelog_rel_path.display(),
pkg_id.display_name()
),
package: Some(pkg_id.clone()),
path: Some(changelog_rel_path.to_path_buf()),
escalated_by: None,
governed_by: None,
});
None
}
}
}
pub fn plan_publish<R: CommandRunner, D: DependencyResolver>(
ws: &Workspace<'_, R, D>,
opts: &PublishOptions,
) -> Result<PublishPlan, GraphError> {
let mut rust_crates = Vec::new();
let mut npm_main_packages = Vec::new();
let mut npm_platform_packages = Vec::new();
let mut pypi_packages = Vec::new();
let mut releases = Vec::new();
let base_versions = ws.base_versions()?;
let inference = crate::infer::NoInference;
let mut diagnostics: Vec<callisto_model::Diagnostic> = Vec::new();
let version_plan = match crate::commands::version::plan_version(
ws,
&inference,
&crate::commands::version::VersionOptions::default(),
) {
Ok(plan) => Some(plan),
Err(e) => {
diagnostics.push(callisto_model::Diagnostic {
code: callisto_model::DiagnosticCode::ChangesetReadError,
severity: callisto_model::DiagnosticSeverity::Warning,
message: format!("Could not read changesets: {e}"),
package: None,
path: None,
escalated_by: None,
governed_by: None,
});
None
}
};
let pkg_map: std::collections::HashMap<&callisto_model::PackageId, &callisto_model::Package> =
ws.graph.packages().map(|p| (&p.id, p)).collect();
let all_ids: std::collections::HashSet<_> = pkg_map.keys().map(|&id| id.clone()).collect();
let topo_ids = publish_order(&ws.graph, &all_ids)?;
let head_sha = match ws.git_access().head_sha() {
Ok(sha) => Some(sha),
Err(e) => {
diagnostics.push(callisto_model::Diagnostic {
code: callisto_model::DiagnosticCode::GitDiscoveryFailed,
severity: callisto_model::DiagnosticSeverity::Warning,
message: format!("Could not resolve HEAD SHA: {e}; release entries will be omitted from the plan"),
package: None,
path: None,
escalated_by: None,
governed_by: None,
});
None
}
};
let tag_index = match ws.tags() {
Ok(idx) => Some(idx),
Err(e) => {
diagnostics.push(callisto_model::Diagnostic {
code: callisto_model::DiagnosticCode::GitDiscoveryFailed,
severity: callisto_model::DiagnosticSeverity::Warning,
message: format!("Could not read git tags: {e}; all packages treated as release candidates"),
package: None,
path: None,
escalated_by: None,
governed_by: None,
});
None
}
};
for id in &topo_ids {
let pkg = match pkg_map.get(id) {
Some(&p) => p,
None => continue,
};
let bump_info = version_plan
.as_ref()
.and_then(|plan| plan.bumps.iter().find(|b| b.package == pkg.id));
let (is_release, ver) = if let Some(bump) = bump_info {
(true, bump.to.clone())
} else {
let cur_ver = base_versions.get(&pkg.id).cloned().ok_or_else(|| {
GraphError::Manifest(callisto_model::ManifestError::MissingField {
path: pkg.manifests.first().map(|m| m.path.clone()).unwrap_or_default(),
field: "version",
})
})?;
let tag_match = tag_index
.and_then(|idx| idx.last_tag(&pkg.id))
.map(|t| t.version == cur_ver)
.unwrap_or(false);
(!tag_match, cur_ver)
};
if is_release {
let mut publishes_cargo = false;
let mut publishes_npm = false;
let mut publishes_pypi = false;
let mut npm_registry_url: Option<String> = None;
let mut npm_access: Option<callisto_model::NpmAccess> = None;
let mut has_dispatchable_target = false;
for target in &pkg.publish_to {
match target {
callisto_model::PublishTarget::CratesIo => {
publishes_cargo = true;
has_dispatchable_target = true;
}
callisto_model::PublishTarget::Npm { registry, access } => {
publishes_npm = true;
has_dispatchable_target = true;
if npm_registry_url.is_none() {
if let Some(url) = registry {
validate_npm_registry_url(url, &pkg.id, &ws.config.registries)?;
}
npm_registry_url = registry.clone();
npm_access = *access;
}
}
callisto_model::PublishTarget::Pypi { .. } => {
publishes_pypi = true;
has_dispatchable_target = true;
}
callisto_model::PublishTarget::NuGet { .. } => {
diagnostics.push(callisto_model::Diagnostic {
code: callisto_model::DiagnosticCode::PublishTargetNotImplemented,
severity: callisto_model::DiagnosticSeverity::Warning,
message: format!(
"package `{}` configures publish-to = [\"nuget\"], but NuGet \
publishing is not yet implemented; this target will not be \
published",
pkg.id.display_name()
),
package: Some(pkg.id.clone()),
path: None,
escalated_by: None,
governed_by: None,
});
}
callisto_model::PublishTarget::GitHubRelease => {
diagnostics.push(callisto_model::Diagnostic {
code: callisto_model::DiagnosticCode::PublishTargetNotImplemented,
severity: callisto_model::DiagnosticSeverity::Warning,
message: format!(
"package `{}` configures publish-to = [\"github-release\"], but \
GitHub Release publishing is not yet implemented; this target \
will not be published",
pkg.id.display_name()
),
package: Some(pkg.id.clone()),
path: None,
escalated_by: None,
governed_by: None,
});
}
callisto_model::PublishTarget::None => {}
#[allow(unreachable_patterns)]
_ => {
diagnostics.push(callisto_model::Diagnostic {
code: callisto_model::DiagnosticCode::PublishTargetNotImplemented,
severity: callisto_model::DiagnosticSeverity::Warning,
message: format!(
"package `{}` configures a publish-to target with no \
implemented dispatch; this target will not be published",
pkg.id.display_name()
),
package: Some(pkg.id.clone()),
path: None,
escalated_by: None,
governed_by: None,
});
}
}
}
let is_platform_pkg = pkg
.manifests
.iter()
.any(|m| matches!(m.role, callisto_model::ManifestRole::Platform { .. }));
let pkg_dir = pkg
.manifests
.first()
.and_then(|m| m.path.parent())
.map(|p| p.to_path_buf())
.unwrap_or_default();
if publishes_cargo {
rust_crates.push(CratePublish {
name: pkg.id.name().to_string(),
version: ver.clone(),
publish_to: callisto_model::RegistryKey(callisto_model::RegistryKey::CRATES_IO.to_string()),
registry: None,
package_dir: if pkg_dir.as_os_str().is_empty() {
None
} else {
Some(pkg_dir.clone())
},
});
}
if publishes_npm {
let tag = if ver.is_prerelease() {
Some("next".to_string())
} else {
None
};
let access = npm_access.or_else(|| {
if pkg.id.name().starts_with('@') {
Some(callisto_model::NpmAccess::Public)
} else {
None
}
});
if is_platform_pkg {
npm_platform_packages.push(callisto_model::NpmPublish {
name: pkg.id.name().to_string(),
version: ver.clone(),
publish_to: callisto_model::RegistryKey(callisto_model::RegistryKey::NPM.to_string()),
package_dir: pkg_dir.clone(),
registry: npm_registry_url.clone(),
tag: tag.clone(),
access,
});
} else {
let platform_deps: Vec<String> = ws
.graph
.dependencies_of(&pkg.id)
.filter(|edge| {
pkg_map
.get(&edge.to)
.map(|p| {
p.manifests
.iter()
.any(|m| matches!(m.role, callisto_model::ManifestRole::Platform { .. }))
})
.unwrap_or(false)
})
.map(|edge| edge.to.name().to_string())
.collect();
npm_main_packages.push(NpmMainPublish {
name: pkg.id.name().to_string(),
version: ver.clone(),
publish_to: callisto_model::RegistryKey(callisto_model::RegistryKey::NPM.to_string()),
package_dir: pkg_dir.clone(),
registry: npm_registry_url,
tag,
access,
depends_on_platforms: platform_deps,
});
}
}
if publishes_pypi {
let index = pkg
.publish_to
.iter()
.find_map(|t| {
if let callisto_model::PublishTarget::Pypi { index } = t {
Some(index.clone())
} else {
None
}
})
.flatten();
pypi_packages.push(PypiPublish {
name: pkg.id.name().to_string(),
version: ver.clone(),
publish_to: RegistryKey(RegistryKey::PYPI.to_string()),
package_dir: pkg_dir,
index,
});
}
if !pkg.publish_to.is_empty()
&& !pkg.publish_to.iter().all(|t| *t == PublishTarget::None)
&& has_dispatchable_target
{
if let (Some(ref sha), Some(idx)) = (&head_sha, tag_index) {
let changelog_section = pkg.changelog.as_ref().and_then(|ch_path| {
resolve_changelog_section(&ws.root, ch_path, &pkg.id, &ver, &mut diagnostics)
});
releases.push(ReleaseEntry {
package: pkg.id.clone(),
tag_name: idx.template(&pkg.id).render(&ver),
sha: sha.clone(),
changelog_section,
is_prerelease: ver.is_prerelease(),
});
}
}
}
}
if !opts.only.is_empty() {
let keep = |name: &str| opts.only.iter().any(|n| n == name);
rust_crates.retain(|c| keep(&c.name));
npm_main_packages.retain(|c| keep(&c.name));
npm_platform_packages.retain(|c| keep(&c.name));
pypi_packages.retain(|c| keep(&c.name));
releases.retain(|r| keep(r.package.name()));
let retained: std::collections::HashSet<&str> = rust_crates
.iter()
.map(|c| c.name.as_str())
.chain(npm_main_packages.iter().map(|c| c.name.as_str()))
.chain(npm_platform_packages.iter().map(|c| c.name.as_str()))
.chain(pypi_packages.iter().map(|c| c.name.as_str()))
.collect();
for requested in &opts.only {
if !retained.contains(requested.as_str()) {
return Err(crate::error::GraphError::UnknownPackage {
id: callisto_model::PackageId::Bare(requested.clone()),
});
}
}
}
Ok(PublishPlan {
schema_version: SCHEMA_VERSION,
rust_crates,
npm_main_packages,
npm_platform_packages,
pypi_packages,
releases,
diagnostics,
})
}
use callisto_model::{
ApplyPermit, Ecosystem, PublishAttempt, PublishAttemptResult, PublishOutcome, PublishReport, RateLimitPolicy,
RegistryClient, RegistryError, TimeProvider, Version,
};
use std::time::Duration;
pub(crate) const MAX_RATE_LIMIT_RETRIES: usize = 10;
const MAX_RETRY_AFTER_SECS: u64 = 600;
pub(crate) const DEFAULT_RATE_LIMIT_WAIT_SECS: u64 = 60;
pub fn parse_retry_after(raw: &str) -> Option<Duration> {
raw.trim().parse::<u64>().ok().map(Duration::from_secs)
}
pub struct SystemTimeProvider;
impl TimeProvider for SystemTimeProvider {
fn now(&self) -> std::time::SystemTime {
std::time::SystemTime::now()
}
fn sleep(&self, duration: Duration) {
std::thread::sleep(duration);
}
}
pub struct AlwaysRetryPolicy;
impl RateLimitPolicy for AlwaysRetryPolicy {
fn check_rate_limit(&self, _retry_after: Duration) -> Result<(), RegistryError> {
Ok(())
}
}
pub struct PublishOrchestrator<R, P, T> {
client: R,
policy: P,
time: T,
progress: Option<Box<dyn Fn(String) + Send + Sync>>,
}
impl<R, P, T> PublishOrchestrator<R, P, T>
where
R: RegistryClient,
P: RateLimitPolicy,
T: TimeProvider,
{
pub fn new(client: R, policy: P, time: T) -> Self {
Self {
client,
policy,
time,
progress: None,
}
}
pub fn with_progress<F: Fn(String) + Send + Sync + 'static>(mut self, f: F) -> Self {
self.progress = Some(Box::new(f));
self
}
pub fn parse_http_429_ttl(retry_after_header: &str) -> Option<Duration> {
parse_retry_after(retry_after_header)
}
fn emit_progress(&self, name: &str, version: &Version) {
if let Some(ref cb) = self.progress {
cb(format!("Publishing {name}@{version}…"));
}
}
pub fn execute(&self, plan: &PublishPlan, permit: &ApplyPermit) -> PublishReport {
let mut attempts = Vec::new();
for rust_crate in &plan.rust_crates {
let pkg_id = PackageId::Prefixed {
ecosystem: Ecosystem::Cargo,
name: rust_crate.name.clone(),
};
self.emit_progress(&rust_crate.name, &rust_crate.version);
attempts.push(self.attempt_publish(pkg_id, rust_crate.version.clone(), permit));
}
for npm_pkg in &plan.npm_platform_packages {
let pkg_id = PackageId::Prefixed {
ecosystem: Ecosystem::Npm,
name: npm_pkg.name.clone(),
};
self.emit_progress(&npm_pkg.name, &npm_pkg.version);
attempts.push(self.attempt_publish(pkg_id, npm_pkg.version.clone(), permit));
}
for npm_pkg in &plan.npm_main_packages {
let pkg_id = PackageId::Prefixed {
ecosystem: Ecosystem::Npm,
name: npm_pkg.name.clone(),
};
self.emit_progress(&npm_pkg.name, &npm_pkg.version);
attempts.push(self.attempt_publish(pkg_id, npm_pkg.version.clone(), permit));
}
for pypi_pkg in &plan.pypi_packages {
let pkg_id = PackageId::Prefixed {
ecosystem: Ecosystem::Pypi,
name: pypi_pkg.name.clone(),
};
self.emit_progress(&pypi_pkg.name, &pypi_pkg.version);
attempts.push(self.attempt_publish(pkg_id, pypi_pkg.version.clone(), permit));
}
PublishReport {
schema_version: callisto_model::SCHEMA_VERSION,
attempts,
diagnostics: Vec::new(),
}
}
fn attempt_publish(&self, package: PackageId, version: Version, permit: &ApplyPermit) -> PublishAttempt {
let result = match self.publish_with_retry(&package, &version, permit) {
Ok(PublishOutcome::Published) => PublishAttemptResult::Published,
Ok(PublishOutcome::AlreadyPublished) => PublishAttemptResult::AlreadyPublished,
Err(err) => PublishAttemptResult::Failed {
kind: err.kind_str().to_string(),
error: err.to_string(),
},
};
PublishAttempt {
package,
version,
result,
}
}
fn publish_with_retry(
&self,
pkg_id: &PackageId,
version: &Version,
permit: &ApplyPermit,
) -> Result<PublishOutcome, RegistryError> {
if self.client.is_published(pkg_id, version).unwrap_or(false) {
return Ok(PublishOutcome::AlreadyPublished);
}
let mut retries = 0usize;
loop {
match self.client.publish(pkg_id, version, permit) {
Ok(outcome @ (PublishOutcome::Published | PublishOutcome::AlreadyPublished)) => return Ok(outcome),
Err(RegistryError::RateLimited(retry_after)) => {
if retry_after > Duration::from_secs(MAX_RETRY_AFTER_SECS) {
return Err(RegistryError::RateLimited(retry_after));
}
retries += 1;
if retries >= MAX_RATE_LIMIT_RETRIES {
return Err(RegistryError::Other(format!(
"rate-limited {MAX_RATE_LIMIT_RETRIES} consecutive times; giving up"
)));
}
self.policy.check_rate_limit(retry_after)?;
self.time.sleep(retry_after);
}
Err(RegistryError::AuthFailed(err)) => {
return Err(RegistryError::AuthFailed(err));
}
Err(err) => {
return Err(err);
}
}
}
}
}
#[cfg(test)]
mod tests {
fn permit() -> ApplyPermit {
ApplyPermit::force_for_tests()
}
use super::*;
use std::sync::Mutex;
use std::time::SystemTime;
struct TestGraph {
packages: Vec<callisto_model::Package>,
edges: Vec<callisto_model::DepEdge>,
}
fn test_package(name: &str) -> callisto_model::Package {
callisto_model::Package {
id: PackageId::parse(name).unwrap(),
manifests: vec![],
changelog: None,
release_trigger: callisto_model::ReleaseTrigger::Changeset,
publish_to: vec![],
tag_template: None,
}
}
fn test_edge(from: &str, to: &str, kind: callisto_model::DepKind) -> callisto_model::DepEdge {
callisto_model::DepEdge {
from: PackageId::parse(from).unwrap(),
to: PackageId::parse(to).unwrap(),
kind,
spec: callisto_model::DepSpec::Opaque("*".to_string()),
from_manifest: std::path::PathBuf::from(format!("{from}/Cargo.toml")),
inherited: false,
}
}
impl DependencyResolver for TestGraph {
fn packages(&self) -> impl Iterator<Item = &callisto_model::Package> {
self.packages.iter()
}
fn dependencies_of(&self, id: &PackageId) -> impl Iterator<Item = &callisto_model::DepEdge> {
self.edges.iter().filter(move |e| &e.from == id)
}
fn dependents_of(&self, id: &PackageId) -> impl Iterator<Item = &callisto_model::DepEdge> {
self.edges.iter().filter(move |e| &e.to == id)
}
}
fn all_ids(graph: &TestGraph) -> HashSet<PackageId> {
graph.packages.iter().map(|p| p.id.clone()).collect()
}
#[test]
fn publish_order_sequences_a_dev_only_dependency_before_its_dependent() {
let graph = TestGraph {
packages: vec![test_package("conventional"), test_package("vcs")],
edges: vec![test_edge("conventional", "vcs", callisto_model::DepKind::Dev)],
};
let order = publish_order(&graph, &all_ids(&graph)).unwrap();
let vcs_pos = order
.iter()
.position(|id| id.name() == "vcs")
.expect("vcs must be in the order");
let conventional_pos = order
.iter()
.position(|id| id.name() == "conventional")
.expect("conventional must be in the order");
assert!(
vcs_pos < conventional_pos,
"vcs (dev-dependency) must publish before conventional; got order: {order:?}"
);
}
#[test]
fn publish_order_tolerates_a_dev_only_cycle_without_hard_failing() {
let graph = TestGraph {
packages: vec![test_package("pkg-a"), test_package("pkg-b")],
edges: vec![
test_edge("pkg-a", "pkg-b", callisto_model::DepKind::Dev),
test_edge("pkg-b", "pkg-a", callisto_model::DepKind::Dev),
],
};
let order = publish_order(&graph, &all_ids(&graph));
assert!(
order.is_ok(),
"a dev-only cycle must not hard-fail publish_order; got {order:?}"
);
assert_eq!(order.unwrap().len(), 2);
}
#[test]
fn publish_order_still_errors_on_a_real_runtime_cycle() {
let graph = TestGraph {
packages: vec![test_package("pkg-a"), test_package("pkg-b")],
edges: vec![
test_edge("pkg-a", "pkg-b", callisto_model::DepKind::Runtime),
test_edge("pkg-b", "pkg-a", callisto_model::DepKind::Runtime),
],
};
let order = publish_order(&graph, &all_ids(&graph));
assert!(
matches!(order, Err(GraphError::Cycle { .. })),
"a real Runtime cycle must still error; got {order:?}"
);
}
struct MockRegistryClient {
published: Mutex<std::collections::HashSet<(PackageId, Version)>>,
responses: Mutex<Vec<Result<PublishOutcome, RegistryError>>>,
}
impl RegistryClient for MockRegistryClient {
fn is_published(&self, package: &PackageId, version: &Version) -> Result<bool, RegistryError> {
let published = self.published.lock().unwrap();
Ok(published.contains(&(package.clone(), version.clone())))
}
fn publish(
&self,
package: &PackageId,
version: &Version,
_permit: &ApplyPermit,
) -> Result<PublishOutcome, RegistryError> {
let mut responses = self.responses.lock().unwrap();
let outcome = match responses.pop() {
Some(res) => res?,
None => PublishOutcome::Published,
};
if matches!(outcome, PublishOutcome::Published) {
let mut published = self.published.lock().unwrap();
published.insert((package.clone(), version.clone()));
}
Ok(outcome)
}
}
struct MockRateLimitPolicy;
impl RateLimitPolicy for MockRateLimitPolicy {
fn check_rate_limit(&self, _retry_after: Duration) -> Result<(), RegistryError> {
Ok(())
}
}
struct MockTimeProvider {
time: Mutex<SystemTime>,
}
impl TimeProvider for MockTimeProvider {
fn now(&self) -> SystemTime {
*self.time.lock().unwrap()
}
fn sleep(&self, duration: Duration) {
let mut time = self.time.lock().unwrap();
*time += duration;
}
}
fn create_test_plan() -> callisto_model::PublishPlan {
callisto_model::PublishPlan {
schema_version: callisto_model::SCHEMA_VERSION,
rust_crates: vec![callisto_model::CratePublish {
name: "test-crate".to_string(),
version: Version::parse("1.0.0", callisto_model::VersionGrammar::SemVer).unwrap(),
publish_to: callisto_model::RegistryKey(callisto_model::RegistryKey::CRATES_IO.to_string()),
registry: None,
package_dir: None,
}],
npm_main_packages: vec![],
npm_platform_packages: vec![],
pypi_packages: vec![],
releases: vec![],
diagnostics: vec![],
}
}
fn pypi_publish_entry(name: &str) -> callisto_model::PypiPublish {
callisto_model::PypiPublish {
name: name.to_string(),
version: v100(),
publish_to: callisto_model::RegistryKey(callisto_model::RegistryKey::PYPI.to_string()),
package_dir: std::path::PathBuf::new(),
index: None,
}
}
#[test]
fn test_publish_success() {
let client = MockRegistryClient {
published: Mutex::new(std::collections::HashSet::new()),
responses: Mutex::new(vec![]),
};
let policy = MockRateLimitPolicy;
let time = MockTimeProvider {
time: Mutex::new(SystemTime::UNIX_EPOCH),
};
let orchestrator = PublishOrchestrator::new(client, policy, time);
let report = orchestrator.execute(&create_test_plan(), &permit());
assert_eq!(report.attempts.len(), 1);
assert!(matches!(report.attempts[0].result, PublishAttemptResult::Published));
assert_eq!(orchestrator.time.now(), SystemTime::UNIX_EPOCH);
}
#[test]
fn test_publish_already_published_is_not_an_error_and_does_not_retry() {
let client = MockRegistryClient {
published: Mutex::new(std::collections::HashSet::new()),
responses: Mutex::new(vec![Ok(PublishOutcome::AlreadyPublished)]),
};
let policy = MockRateLimitPolicy;
let time = MockTimeProvider {
time: Mutex::new(SystemTime::UNIX_EPOCH),
};
let orchestrator = PublishOrchestrator::new(client, policy, time);
let report = orchestrator.execute(&create_test_plan(), &permit());
assert_eq!(report.attempts.len(), 1);
assert!(matches!(
report.attempts[0].result,
PublishAttemptResult::AlreadyPublished
));
assert_eq!(orchestrator.time.now(), SystemTime::UNIX_EPOCH);
}
#[test]
fn test_publish_rate_limit_retry() {
let client = MockRegistryClient {
published: Mutex::new(std::collections::HashSet::new()),
responses: Mutex::new(vec![Err(RegistryError::RateLimited(Duration::from_secs(60)))]),
};
let policy = MockRateLimitPolicy;
let time = MockTimeProvider {
time: Mutex::new(SystemTime::UNIX_EPOCH),
};
let orchestrator = PublishOrchestrator::new(client, policy, time);
let report = orchestrator.execute(&create_test_plan(), &permit());
assert_eq!(report.attempts.len(), 1);
assert!(matches!(report.attempts[0].result, PublishAttemptResult::Published));
assert_eq!(
orchestrator.time.now(),
SystemTime::UNIX_EPOCH + Duration::from_secs(60)
);
}
#[test]
fn test_publish_rate_limit_exceeds_600s() {
let client = MockRegistryClient {
published: Mutex::new(std::collections::HashSet::new()),
responses: Mutex::new(vec![Err(RegistryError::RateLimited(Duration::from_secs(
MAX_RETRY_AFTER_SECS + 1,
)))]),
};
let policy = MockRateLimitPolicy;
let time = MockTimeProvider {
time: Mutex::new(SystemTime::UNIX_EPOCH),
};
let orchestrator = PublishOrchestrator::new(client, policy, time);
let report = orchestrator.execute(&create_test_plan(), &permit());
assert_eq!(report.attempts.len(), 1);
match &report.attempts[0].result {
PublishAttemptResult::Failed { error, .. } => {
assert!(error.contains("601"));
}
other => panic!("expected Failed outcome, got {other:?}"),
}
}
#[test]
fn test_publish_rate_limit_cap_fires_at_exactly_max_retries() {
let rate_limits: Vec<_> = (0..MAX_RATE_LIMIT_RETRIES)
.map(|_| Err(RegistryError::RateLimited(Duration::from_secs(1))))
.collect();
let client = MockRegistryClient {
published: Mutex::new(std::collections::HashSet::new()),
responses: Mutex::new(rate_limits),
};
let policy = MockRateLimitPolicy;
let time = MockTimeProvider {
time: Mutex::new(SystemTime::UNIX_EPOCH),
};
let orchestrator = PublishOrchestrator::new(client, policy, time);
let report = orchestrator.execute(&create_test_plan(), &permit());
assert_eq!(report.attempts.len(), 1);
assert!(
matches!(report.attempts[0].result, PublishAttemptResult::Failed { .. }),
"cap must fire after exactly MAX_RATE_LIMIT_RETRIES ({MAX_RATE_LIMIT_RETRIES}) \
responses; got: {:?}",
report.attempts[0].result
);
}
#[test]
fn test_publish_rate_limit_cap_aborts_after_max_retries() {
let many_rate_limits: Vec<_> = (0..=MAX_RATE_LIMIT_RETRIES)
.map(|_| Err(RegistryError::RateLimited(Duration::from_secs(1))))
.collect();
let client = MockRegistryClient {
published: Mutex::new(std::collections::HashSet::new()),
responses: Mutex::new(many_rate_limits),
};
let policy = MockRateLimitPolicy;
let time = MockTimeProvider {
time: Mutex::new(SystemTime::UNIX_EPOCH),
};
let orchestrator = PublishOrchestrator::new(client, policy, time);
let report = orchestrator.execute(&create_test_plan(), &permit());
assert_eq!(report.attempts.len(), 1);
match &report.attempts[0].result {
PublishAttemptResult::Failed { error, .. } => {
assert!(
error.to_lowercase().contains("rate") || error.to_lowercase().contains("retry"),
"failure message should mention rate-limit or retry; got: {error}"
);
}
other => panic!("expected Failed after retry cap, got {other:?}"),
}
}
#[test]
fn test_publish_auth_fail_fast() {
let client = MockRegistryClient {
published: Mutex::new(std::collections::HashSet::new()),
responses: Mutex::new(vec![Err(RegistryError::AuthFailed("Invalid token".to_string()))]),
};
let policy = MockRateLimitPolicy;
let time = MockTimeProvider {
time: Mutex::new(SystemTime::UNIX_EPOCH),
};
let orchestrator = PublishOrchestrator::new(client, policy, time);
let report = orchestrator.execute(&create_test_plan(), &permit());
assert_eq!(report.attempts.len(), 1);
match &report.attempts[0].result {
PublishAttemptResult::Failed { error, .. } => {
assert!(error.contains("Invalid token"));
}
other => panic!("expected Failed outcome, got {other:?}"),
}
}
fn v100() -> Version {
Version::parse("1.0.0", callisto_model::VersionGrammar::SemVer).unwrap()
}
fn crate_publish(name: &str) -> callisto_model::CratePublish {
callisto_model::CratePublish {
name: name.to_string(),
version: v100(),
publish_to: callisto_model::RegistryKey(callisto_model::RegistryKey::CRATES_IO.to_string()),
registry: None,
package_dir: None,
}
}
#[test]
fn test_publish_execute_reports_distinct_per_package_outcomes() {
let client = MockRegistryClient {
published: Mutex::new(std::collections::HashSet::new()),
responses: Mutex::new(vec![
Err(RegistryError::AuthFailed("bad token".to_string())), Ok(PublishOutcome::AlreadyPublished), Ok(PublishOutcome::Published), ]),
};
let policy = MockRateLimitPolicy;
let time = MockTimeProvider {
time: Mutex::new(SystemTime::UNIX_EPOCH),
};
let orchestrator = PublishOrchestrator::new(client, policy, time);
let plan = callisto_model::PublishPlan {
schema_version: callisto_model::SCHEMA_VERSION,
rust_crates: vec![
crate_publish("crate-a"),
crate_publish("crate-b"),
crate_publish("crate-c"),
],
npm_main_packages: vec![],
npm_platform_packages: vec![],
pypi_packages: vec![],
releases: vec![],
diagnostics: vec![],
};
let report = orchestrator.execute(&plan, &permit());
assert_eq!(report.attempts.len(), 3);
assert_eq!(report.attempts[0].package.name(), "crate-a");
assert!(matches!(
report.attempts[0].result,
callisto_model::PublishAttemptResult::Published
));
assert_eq!(report.attempts[1].package.name(), "crate-b");
assert!(matches!(
report.attempts[1].result,
callisto_model::PublishAttemptResult::AlreadyPublished
));
assert_eq!(report.attempts[2].package.name(), "crate-c");
match &report.attempts[2].result {
callisto_model::PublishAttemptResult::Failed { error, .. } => {
assert!(error.contains("bad token"));
}
other => panic!("expected Failed outcome for crate-c, got {other:?}"),
}
}
#[test]
fn test_parse_ttl() {
assert_eq!(
PublishOrchestrator::<MockRegistryClient, MockRateLimitPolicy, MockTimeProvider>::parse_http_429_ttl("120"),
Some(Duration::from_secs(120))
);
assert_eq!(
PublishOrchestrator::<MockRegistryClient, MockRateLimitPolicy, MockTimeProvider>::parse_http_429_ttl(
"invalid"
),
None
);
}
#[test]
fn test_execute_dispatches_pypi_packages() {
let client = MockRegistryClient {
published: Mutex::new(std::collections::HashSet::new()),
responses: Mutex::new(vec![
Ok(PublishOutcome::AlreadyPublished), Ok(PublishOutcome::Published), ]),
};
let policy = MockRateLimitPolicy;
let time = MockTimeProvider {
time: Mutex::new(SystemTime::UNIX_EPOCH),
};
let orchestrator = PublishOrchestrator::new(client, policy, time);
let plan = callisto_model::PublishPlan {
schema_version: callisto_model::SCHEMA_VERSION,
rust_crates: vec![],
npm_main_packages: vec![],
npm_platform_packages: vec![],
pypi_packages: vec![pypi_publish_entry("pypi-a"), pypi_publish_entry("pypi-b")],
releases: vec![],
diagnostics: vec![],
};
let report = orchestrator.execute(&plan, &permit());
assert_eq!(report.attempts.len(), 2, "expected one attempt per pypi package");
assert_eq!(report.attempts[0].package.name(), "pypi-a");
assert!(
matches!(report.attempts[0].result, PublishAttemptResult::Published),
"pypi-a should be Published"
);
assert_eq!(report.attempts[1].package.name(), "pypi-b");
assert!(
matches!(report.attempts[1].result, PublishAttemptResult::AlreadyPublished),
"pypi-b should be AlreadyPublished"
);
}
#[test]
fn test_npm_platforms_published_before_mains() {
struct RecordingClient {
order: Mutex<Vec<String>>,
}
impl RegistryClient for RecordingClient {
fn is_published(&self, _pkg: &PackageId, _ver: &Version) -> Result<bool, RegistryError> {
Ok(false)
}
fn publish(
&self,
pkg: &PackageId,
_ver: &Version,
_permit: &ApplyPermit,
) -> Result<PublishOutcome, RegistryError> {
self.order.lock().unwrap().push(pkg.name().to_string());
Ok(PublishOutcome::Published)
}
}
let client = RecordingClient {
order: Mutex::new(Vec::new()),
};
let policy = MockRateLimitPolicy;
let time = MockTimeProvider {
time: Mutex::new(SystemTime::UNIX_EPOCH),
};
let orchestrator = PublishOrchestrator::new(client, policy, time);
let npm_version = v100();
let plan = callisto_model::PublishPlan {
schema_version: callisto_model::SCHEMA_VERSION,
rust_crates: vec![],
npm_platform_packages: vec![callisto_model::NpmPublish {
name: "platform-linux".to_string(),
version: npm_version.clone(),
publish_to: callisto_model::RegistryKey(callisto_model::RegistryKey::NPM.to_string()),
package_dir: std::path::PathBuf::new(),
registry: None,
tag: None,
access: None,
}],
npm_main_packages: vec![callisto_model::NpmMainPublish {
name: "main-package".to_string(),
version: npm_version.clone(),
publish_to: callisto_model::RegistryKey(callisto_model::RegistryKey::NPM.to_string()),
package_dir: std::path::PathBuf::new(),
registry: None,
tag: None,
access: None,
depends_on_platforms: vec!["platform-linux".to_string()],
}],
pypi_packages: vec![],
releases: vec![],
diagnostics: vec![],
};
drop(orchestrator.execute(&plan, &permit()));
let order = orchestrator.client.order.lock().unwrap();
let platform_pos = order
.iter()
.position(|n| n == "platform-linux")
.expect("platform-linux was not published");
let main_pos = order
.iter()
.position(|n| n == "main-package")
.expect("main-package was not published");
assert!(
platform_pos < main_pos,
"platform packages must be published before main packages, but got order: {order:?}"
);
}
#[test]
fn test_execute_pypi_auth_failure_is_recorded_not_propagated() {
let client = MockRegistryClient {
published: Mutex::new(std::collections::HashSet::new()),
responses: Mutex::new(vec![Err(RegistryError::AuthFailed("invalid PyPI token".to_string()))]),
};
let policy = MockRateLimitPolicy;
let time = MockTimeProvider {
time: Mutex::new(SystemTime::UNIX_EPOCH),
};
let orchestrator = PublishOrchestrator::new(client, policy, time);
let plan = callisto_model::PublishPlan {
schema_version: callisto_model::SCHEMA_VERSION,
rust_crates: vec![],
npm_main_packages: vec![],
npm_platform_packages: vec![],
pypi_packages: vec![pypi_publish_entry("bad-pkg")],
releases: vec![],
diagnostics: vec![],
};
let report = orchestrator.execute(&plan, &permit());
assert_eq!(report.attempts.len(), 1);
match &report.attempts[0].result {
PublishAttemptResult::Failed { error, .. } => {
assert!(error.contains("invalid PyPI token"));
}
other => panic!("expected Failed, got {other:?}"),
}
}
#[test]
fn progress_callback_is_called_once_per_package_before_attempt() {
use std::sync::Arc;
let messages: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let messages_clone = Arc::clone(&messages);
let client = MockRegistryClient {
published: Mutex::new(std::collections::HashSet::new()),
responses: Mutex::new(vec![Ok(PublishOutcome::Published), Ok(PublishOutcome::Published)]),
};
let policy = MockRateLimitPolicy;
let time = MockTimeProvider {
time: Mutex::new(std::time::SystemTime::UNIX_EPOCH),
};
let plan = callisto_model::PublishPlan {
schema_version: callisto_model::SCHEMA_VERSION,
rust_crates: vec![crate_publish("crate-a"), crate_publish("crate-b")],
npm_main_packages: vec![],
npm_platform_packages: vec![],
pypi_packages: vec![],
releases: vec![],
diagnostics: vec![],
};
let orchestrator = PublishOrchestrator::new(client, policy, time).with_progress(move |msg: String| {
messages_clone.lock().unwrap().push(msg);
});
let _report = orchestrator.execute(&plan, &permit());
let recorded = messages.lock().unwrap().clone();
assert_eq!(
recorded.len(),
2,
"expected 2 progress messages (one per package); got: {recorded:?}"
);
assert!(
recorded[0].contains("crate-a"),
"first progress message must mention crate-a; got: {:?}",
recorded[0]
);
assert!(
recorded[1].contains("crate-b"),
"second progress message must mention crate-b; got: {:?}",
recorded[1]
);
}
#[test]
fn is_published_error_is_ignored_and_publish_proceeds() {
struct FlakyPreCheckClient {
publish_called: Mutex<bool>,
}
impl RegistryClient for FlakyPreCheckClient {
fn is_published(&self, _pkg: &PackageId, _ver: &Version) -> Result<bool, RegistryError> {
Err(RegistryError::RateLimited(Duration::from_secs(5)))
}
fn publish(
&self,
_pkg: &PackageId,
_ver: &Version,
_permit: &ApplyPermit,
) -> Result<PublishOutcome, RegistryError> {
*self.publish_called.lock().unwrap() = true;
Ok(PublishOutcome::Published)
}
}
let client = FlakyPreCheckClient {
publish_called: Mutex::new(false),
};
let policy = MockRateLimitPolicy;
let time = MockTimeProvider {
time: Mutex::new(SystemTime::UNIX_EPOCH),
};
let orchestrator = PublishOrchestrator::new(client, policy, time);
let report = orchestrator.execute(&create_test_plan(), &permit());
assert!(
*orchestrator.client.publish_called.lock().unwrap(),
"publish() must be called even when is_published() returns an error"
);
assert_eq!(report.attempts.len(), 1, "one attempt must be recorded for the package");
assert!(
matches!(report.attempts[0].result, PublishAttemptResult::Published),
"result must be Published when is_published() errs and publish() succeeds; \
got: {:?}",
report.attempts[0].result
);
}
}