use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::VecDeque;
use std::env;
use std::fs;
use std::path::PathBuf;
use httpmock::Method::GET;
use httpmock::MockServer;
use monochange_cargo::extract_workspace_package_table;
use monochange_cargo::read_workspace_package_table;
use monochange_cargo::write_cargo_placeholder_manifest;
use monochange_core::DependencyKind;
use monochange_core::PackageRecord;
use monochange_core::PublishAttestationSettings;
use monochange_core::PublishMode;
use monochange_core::PublishRegistry;
use monochange_core::PublishState;
use monochange_core::ReleaseRecord;
use monochange_core::SourceProvider;
use monochange_core::TrustedPublishingSettings;
use monochange_github::GITHUB_ACTIONS_ID_TOKEN_REQUEST_TOKEN;
use monochange_github::GITHUB_ACTIONS_ID_TOKEN_REQUEST_URL;
use monochange_github::GitHubTrustContext;
use monochange_github::json_value_contains;
use monochange_github::parse_github_workflow_ref;
use monochange_github::resolve_github_job_environment;
use monochange_github::trust_list_contains_context;
use monochange_npm::append_npm_trust_environment_arg;
use monochange_npm::build_npm_trust_command;
use monochange_publish::CommandExecutor;
use monochange_publish::CommandOutput;
use monochange_publish::CommandSpec;
use monochange_publish::PLACEHOLDER_VERSION;
use monochange_publish::ProcessCommandExecutor;
use monochange_publish::RegistryEndpoints;
use monochange_publish::append_publish_dry_run_args;
use monochange_publish::build_npm_placeholder_publish_command;
use monochange_publish::build_npm_release_publish_command;
use monochange_publish::build_publish_command;
use monochange_publish::crates_io_index_entry_path;
use monochange_publish::crates_io_index_version_exists;
use monochange_publish::default_registry_kind_for_ecosystem;
use monochange_publish::ensure_publish_report_succeeded;
use monochange_publish::filter_pending_publish_requests_with_transport;
use monochange_publish::forbidden_npm_token_env_keys;
use monochange_publish::publish_report_json_error;
use monochange_publish::registry_version_exists;
use monochange_publish::render_command;
use monochange_publish::render_command_error;
use monochange_publish::resolve_placeholder_readme;
use monochange_publish::resolve_registry_kind;
use monochange_publish::write_publish_report_artifact;
use monochange_test_helpers::git;
use monochange_test_helpers::install_rustls_ring_provider;
use reqwest::Client;
use semver::Version;
use serde_json::Value as JsonValue;
use temp_env::with_vars;
use tempfile::TempDir;
use toml::Value as TomlValue;
use super::*;
use crate::tests::TEST_ENV_LOCK;
const NPM_TRUST_DOCS_URL: &str = "https://docs.npmjs.com/cli/v11/commands/npm-trust";
const CRATES_TRUST_DOCS_URL: &str = "https://crates.io/docs/trusted-publishing";
const DART_TRUST_DOCS_URL: &str = "https://dart.dev/tools/pub/automated-publishing";
const JSR_TRUST_DOCS_URL: &str = "https://jsr.io/docs/publishing-packages";
const PYPI_TRUST_DOCS_URL: &str = "https://docs.pypi.org/trusted-publishers/";
fn trust_docs_url(registry: RegistryKind) -> &'static str {
(if registry == RegistryKind::CratesIo {
CRATES_TRUST_DOCS_URL
} else if registry == RegistryKind::PubDev {
DART_TRUST_DOCS_URL
} else if registry == RegistryKind::Jsr {
JSR_TRUST_DOCS_URL
} else if registry == RegistryKind::Pypi {
PYPI_TRUST_DOCS_URL
} else if registry == RegistryKind::GoProxy {
"https://go.dev/ref/mod#publishing"
} else {
NPM_TRUST_DOCS_URL
}) as _
}
struct FakeExecutor {
outputs: VecDeque<CommandOutput>,
commands: Vec<CommandSpec>,
}
impl FakeExecutor {
fn new(outputs: Vec<CommandOutput>) -> Self {
Self {
outputs: VecDeque::from(outputs),
commands: Vec::new(),
}
}
}
impl CommandExecutor for FakeExecutor {
fn run(&mut self, spec: &CommandSpec) -> MonochangeResult<CommandOutput> {
self.commands.push(spec.clone());
self.outputs
.pop_front()
.ok_or_else(|| MonochangeError::Discovery("missing fake command output".to_string()))
}
}
fn sample_request(registry: RegistryKind) -> PublishRequest {
PublishRequest {
package_id: "pkg".to_string(),
package_name: if registry == RegistryKind::Jsr {
"@scope/pkg".to_string()
} else {
"pkg".to_string()
},
ecosystem: if registry == RegistryKind::CratesIo {
Ecosystem::Cargo
} else if registry == RegistryKind::Npm {
Ecosystem::Npm
} else if registry == RegistryKind::PubDev {
Ecosystem::Dart
} else if registry == RegistryKind::Pypi {
Ecosystem::Python
} else if registry == RegistryKind::GoProxy {
Ecosystem::Go
} else {
Ecosystem::Deno
},
manifest_path: PathBuf::from("/workspace/pkg/manifest"),
package_root: PathBuf::from("/workspace/pkg"),
registry,
package_manager: (registry == RegistryKind::Npm).then(|| "npm".to_string()),
package_metadata: BTreeMap::new(),
mode: PublishMode::Builtin,
version: "1.2.3".to_string(),
placeholder: false,
trusted_publishing: TrustedPublishingSettings {
enabled: false,
repository: None,
workflow: None,
environment: None,
},
attestations: PublishAttestationSettings::default(),
placeholder_readme: "placeholder".to_string(),
}
}
fn sample_publish_outcome(package: &str, status: PackagePublishStatus) -> PackagePublishOutcome {
PackagePublishOutcome {
package: package.to_string(),
ecosystem: Ecosystem::Npm,
registry: RegistryKind::Npm.to_string(),
version: "1.2.3".to_string(),
status,
message: format!("{status:?}"),
placeholder: false,
trusted_publishing: disabled_trust_outcome(),
command: None,
stdout: None,
stderr: None,
}
}
fn sample_source() -> SourceConfiguration {
SourceConfiguration {
provider: SourceProvider::GitHub,
owner: "monochange".to_string(),
repo: "monochange".to_string(),
host: None,
api_url: None,
releases: monochange_core::ProviderReleaseSettings::default(),
pull_requests: monochange_core::ProviderMergeRequestSettings::default(),
}
}
fn sample_prepared_release(
root: &Path,
package_publications: Vec<PackagePublicationTarget>,
) -> PreparedRelease {
PreparedRelease {
plan: monochange_core::ReleasePlan {
workspace_root: root.to_path_buf(),
decisions: Vec::new(),
groups: Vec::new(),
warnings: Vec::new(),
unresolved_items: Vec::new(),
compatibility_evidence: Vec::new(),
},
changeset_paths: Vec::new(),
changesets: Vec::new(),
released_packages: Vec::new(),
package_publications,
version: None,
group_version: None,
release_targets: Vec::new(),
changed_files: Vec::new(),
changelogs: Vec::new(),
updated_changelogs: Vec::new(),
deleted_changesets: Vec::new(),
dry_run: true,
}
}
fn trusted_request(registry: RegistryKind) -> PublishRequest {
let mut request = sample_request(registry);
request.trusted_publishing.enabled = true;
request
}
fn trusted_provenance_request(registry: RegistryKind) -> PublishRequest {
let mut request = trusted_request(registry);
request.attestations.require_registry_provenance = true;
request
}
fn github_oidc_env() -> BTreeMap<String, String> {
BTreeMap::from([
(
"GITHUB_REPOSITORY".to_string(),
"monochange/monochange".to_string(),
),
(
"GITHUB_WORKFLOW_REF".to_string(),
"monochange/monochange/.github/workflows/publish.yml@refs/heads/main".to_string(),
),
("GITHUB_ACTIONS".to_string(), "true".to_string()),
("GITHUB_JOB".to_string(), "release".to_string()),
(
GITHUB_ACTIONS_ID_TOKEN_REQUEST_URL.to_string(),
"https://token.actions.githubusercontent.com".to_string(),
),
(
GITHUB_ACTIONS_ID_TOKEN_REQUEST_TOKEN.to_string(),
"request-token".to_string(),
),
])
}
fn sample_endpoints(base_url: &str) -> RegistryEndpoints {
RegistryEndpoints {
npm_registry: base_url.to_string(),
crates_io_api: base_url.to_string(),
crates_io_index: base_url.to_string(),
pub_dev_api: base_url.to_string(),
jsr_base: base_url.to_string(),
pypi_api: base_url.to_string(),
go_proxy: base_url.to_string(),
}
}
fn with_locked_env_vars<T>(action: impl FnOnce() -> T) -> T {
let _env_lock = TEST_ENV_LOCK
.lock()
.unwrap_or_else(|error| panic!("test env lock poisoned: {error}"));
action()
}
fn build_npm_trust_list_command(request: &PublishRequest) -> CommandSpec {
CommandSpec {
program: "npm".to_string(),
args: vec![
"trust".to_string(),
"ls".to_string(),
request.package_name.clone(),
],
cwd: PathBuf::new(),
}
}
fn configure_npm_trusted_publishing(
request: &PublishRequest,
source: Option<&SourceConfiguration>,
root: &Path,
env_map: &BTreeMap<String, String>,
executor: &mut dyn CommandExecutor,
) -> MonochangeResult<TrustedPublishingOutcome> {
let context = resolve_github_trust_context(root, source, &request.trusted_publishing, env_map)?;
let list_command = build_npm_trust_list_command(request);
let list_output = executor.run(&list_command)?;
if trust_list_contains_context(&list_output.stdout, &context) {
return Ok(TrustedPublishingOutcome {
status: TrustedPublishingStatus::Configured,
repository: Some(context.repository),
workflow: Some(context.workflow),
environment: context.environment,
setup_url: Some(manual_setup_url(request)),
message: "npm trusted publishing already matches the current GitHub workflow"
.to_string(),
});
}
let trust_command = build_npm_trust_command(request, &context);
let trust_output = executor.run(&trust_command)?;
if !trust_output.success {
return Err(MonochangeError::Discovery(format!(
"`{}` failed: {}",
render_command(&trust_command),
render_command_error(&trust_output)
)));
}
let verify_output = executor.run(&list_command)?;
if !trust_list_contains_context(&verify_output.stdout, &context) {
return Err(MonochangeError::Discovery(format!(
"npm trusted publishing could not be verified for `{}` after running `{}`",
request.package_name,
render_command(&trust_command)
)));
}
Ok(TrustedPublishingOutcome {
status: TrustedPublishingStatus::Configured,
repository: Some(context.repository),
workflow: Some(context.workflow),
environment: context.environment,
setup_url: Some(manual_setup_url(request)),
message: "configured npm trusted publishing for the current GitHub workflow".to_string(),
})
}
fn workflow_root() -> TempDir {
let root = tempfile::tempdir().expect("tempdir:");
let workflows = root.path().join(".github/workflows");
fs::create_dir_all(&workflows).expect("mkdir:");
fs::write(
workflows.join("publish.yml"),
"jobs:\n release:\n environment: publisher\n",
)
.expect("write workflow:");
root
}
fn sample_configuration(
packages: &[(&str, monochange_core::PackageType, bool)],
) -> WorkspaceConfiguration {
WorkspaceConfiguration {
root_path: PathBuf::from("/workspace"),
defaults: monochange_core::WorkspaceDefaults::default(),
changelog: monochange_core::ChangelogSettings::default(),
prerelease: monochange_core::PrereleaseConfiguration::default(),
packages: packages
.iter()
.map(|(id, package_type, enabled)| {
monochange_core::PackageDefinition {
id: (*id).to_string(),
path: PathBuf::from(id),
package_type: *package_type,
changelog: None,
excluded_changelog_types: Vec::new(),
empty_update_message: None,
release_title: None,
changelog_version_title: None,
versioned_files: Vec::new(),
ignore_ecosystem_versioned_files: false,
ignored_paths: Vec::new(),
additional_paths: Vec::new(),
tag: true,
release: true,
version_format: monochange_core::VersionFormat::Primary,
publish: monochange_core::PublishSettings {
enabled: *enabled,
..monochange_core::PublishSettings::default()
},
}
})
.collect(),
groups: Vec::new(),
cli: Vec::new(),
changesets: monochange_core::ChangesetSettings::default(),
source: None,
lints: monochange_core::lint::WorkspaceLintSettings::default(),
cargo: monochange_core::EcosystemSettings::default(),
npm: monochange_core::EcosystemSettings::default(),
deno: monochange_core::EcosystemSettings::default(),
dart: monochange_core::EcosystemSettings::default(),
python: monochange_core::EcosystemSettings::default(),
go: monochange_core::EcosystemSettings::default(),
}
}
fn commit_release_record(root: &Path, publications: Vec<PackagePublicationTarget>) {
let record = ReleaseRecord {
schema_version: monochange_core::RELEASE_RECORD_SCHEMA_VERSION.to_string(),
kind: monochange_core::RELEASE_RECORD_KIND.to_string(),
created_at: "2026-04-14T08:00:00Z".to_string(),
command: "release-pr".to_string(),
version: Some("1.2.3".to_string()),
versions: BTreeMap::from([("pkg".to_string(), "1.2.3".to_string())]),
release_targets: vec![monochange_core::ReleaseRecordTarget {
id: "pkg".to_string(),
kind: monochange_core::ReleaseOwnerKind::Package,
version: "1.2.3".to_string(),
tag: true,
release: true,
version_format: monochange_core::VersionFormat::Primary,
tag_name: "pkg-v1.2.3".to_string(),
members: Vec::new(),
}],
released_packages: vec!["pkg".to_string()],
changed_files: vec![PathBuf::from("tracked.txt")],
package_publications: publications,
updated_changelogs: Vec::new(),
deleted_changesets: Vec::new(),
changesets: Vec::new(),
changelogs: Vec::new(),
provider: None,
};
let json = serde_json::to_string_pretty(&record).expect("serialize release record");
let hash = {
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
let mut hasher = DefaultHasher::new();
for target in &record.release_targets {
hasher.write(target.id.as_bytes());
hasher.write(target.version.as_bytes());
}
format!("{:016x}", hasher.finish())
};
let dir = root.join(".monochange/releases").join(&hash);
fs::create_dir_all(&dir).expect("create release record dir");
let record_path = dir.join("release.json");
fs::write(&record_path, &json).expect("write release record");
fs::write(root.join("tracked.txt"), "release\n").expect("write tracked release file");
git(root, &["add", "."]);
git(
root,
&["commit", "--message", "chore(release): prepare release"],
);
}
#[test]
fn parse_github_workflow_ref_extracts_filename() {
assert_eq!(
parse_github_workflow_ref(
"monochange/monochange/.github/workflows/publish.yml@refs/heads/main"
),
Some("publish.yml".to_string())
);
assert_eq!(parse_github_workflow_ref("bad-value"), None);
}
#[test]
fn resolve_github_job_environment_reads_string_and_mapping_values() {
let tempdir = tempfile::tempdir().expect("tempdir:");
let workflows = tempdir.path().join(".github/workflows");
fs::create_dir_all(&workflows).expect("mkdir:");
fs::write(
workflows.join("publish.yml"),
r"
jobs:
release:
environment: publisher
docs:
environment:
name: docs
",
)
.expect("write workflow:");
let release_env = BTreeMap::from([("GITHUB_JOB".to_string(), "release".to_string())]);
let docs_env = BTreeMap::from([("GITHUB_JOB".to_string(), "docs".to_string())]);
assert_eq!(
resolve_github_job_environment(tempdir.path(), "publish.yml", &release_env),
Some("publisher".to_string())
);
assert_eq!(
resolve_github_job_environment(tempdir.path(), "publish.yml", &docs_env),
Some("docs".to_string())
);
}
#[test]
fn resolve_github_job_environment_returns_none_for_missing_inputs() {
let tempdir = tempfile::tempdir().expect("tempdir:");
let workflows = tempdir.path().join(".github/workflows");
fs::create_dir_all(&workflows).expect("mkdir:");
fs::write(workflows.join("invalid.yml"), "jobs: [").expect("write workflow:");
fs::write(
workflows.join("missing-env.yml"),
"jobs:\n release:\n runs-on: ubuntu-latest\n",
)
.expect("write workflow:");
assert_eq!(
resolve_github_job_environment(tempdir.path(), "publish.yml", &BTreeMap::new()),
None
);
assert_eq!(
resolve_github_job_environment(
tempdir.path(),
"missing.yml",
&BTreeMap::from([("GITHUB_JOB".to_string(), "release".to_string())]),
),
None
);
assert_eq!(
resolve_github_job_environment(
tempdir.path(),
"invalid.yml",
&BTreeMap::from([("GITHUB_JOB".to_string(), "release".to_string())]),
),
None
);
assert_eq!(
resolve_github_job_environment(
tempdir.path(),
"missing-env.yml",
&BTreeMap::from([("GITHUB_JOB".to_string(), "release".to_string())]),
),
None
);
}
#[test]
fn resolve_github_trust_context_prefers_explicit_settings() {
let trust = TrustedPublishingSettings {
enabled: true,
repository: Some("owner/repo".to_string()),
workflow: Some("publish.yml".to_string()),
environment: Some("publisher".to_string()),
};
let context = resolve_github_trust_context(Path::new("."), None, &trust, &BTreeMap::new())
.expect("context:");
assert_eq!(
context,
GitHubTrustContext {
repository: "owner/repo".to_string(),
workflow: "publish.yml".to_string(),
environment: Some("publisher".to_string()),
}
);
}
#[test]
fn resolve_github_trust_context_falls_back_to_source_and_environment() {
let root = workflow_root();
let context = resolve_github_trust_context(
root.path(),
Some(&sample_source()),
&TrustedPublishingSettings {
enabled: true,
repository: None,
workflow: None,
environment: None,
},
&BTreeMap::from([
(
"GITHUB_WORKFLOW_REF".to_string(),
"monochange/monochange/.github/workflows/publish.yml@refs/heads/main".to_string(),
),
("GITHUB_JOB".to_string(), "release".to_string()),
]),
)
.expect("context:");
assert_eq!(context.repository, "monochange/monochange");
assert_eq!(context.workflow, "publish.yml");
assert_eq!(context.environment, Some("publisher".to_string()));
}
#[test]
fn resolve_github_trust_context_requires_repository_and_workflow() {
let missing_repository = resolve_github_trust_context(
Path::new("."),
None,
&TrustedPublishingSettings {
enabled: true,
repository: None,
workflow: Some("publish.yml".to_string()),
environment: None,
},
&BTreeMap::new(),
)
.expect_err("expected repository error");
assert!(
missing_repository
.to_string()
.contains("could not determine the GitHub repository")
);
let missing_workflow = resolve_github_trust_context(
Path::new("."),
Some(&sample_source()),
&TrustedPublishingSettings {
enabled: true,
repository: None,
workflow: None,
environment: None,
},
&BTreeMap::new(),
)
.expect_err("expected workflow error");
assert!(
missing_workflow
.to_string()
.contains("could not determine the GitHub workflow")
);
}
#[test]
fn trust_list_contains_context_supports_json_and_text() {
let context = GitHubTrustContext {
repository: "monochange/monochange".to_string(),
workflow: "publish.yml".to_string(),
environment: Some("publisher".to_string()),
};
assert!(trust_list_contains_context(
r#"{"publisher":{"repository":"monochange/monochange","workflow":"publish.yml","environment":"publisher"}}"#,
&context,
));
assert!(trust_list_contains_context(
"repository monochange/monochange workflow publish.yml environment publisher",
&context,
));
}
#[test]
fn append_npm_trust_environment_arg_ignores_missing_environment() {
let mut args = vec!["trust".to_string()];
append_npm_trust_environment_arg(&mut args, None);
assert_eq!(args, vec!["trust".to_string()]);
append_npm_trust_environment_arg(&mut args, Some(&"publisher".to_string()));
assert_eq!(
args,
vec![
"trust".to_string(),
"--env".to_string(),
"publisher".to_string(),
]
);
}
#[test]
fn trust_list_contains_context_checks_arrays_and_missing_values() {
let context = GitHubTrustContext {
repository: "monochange/monochange".to_string(),
workflow: "publish.yml".to_string(),
environment: None,
};
assert!(trust_list_contains_context(
r#"[{"repository":"monochange/monochange"},{"workflow":"publish.yml"}]"#,
&context,
));
assert!(!json_value_contains(&serde_json::json!(false), "publish"));
assert!(!trust_list_contains_context(
r#"{"repository":"monochange/monochange"}"#,
&context
));
}
#[test]
fn resolve_registry_kind_defaults_and_custom_errors_match_expectations() {
assert_eq!(
resolve_registry_kind(None, Ecosystem::Cargo).expect("cargo registry:"),
RegistryKind::CratesIo
);
assert_eq!(
resolve_registry_kind(None, Ecosystem::Npm).expect("npm registry:"),
RegistryKind::Npm
);
assert_eq!(
resolve_registry_kind(None, Ecosystem::Deno).expect("jsr registry:"),
RegistryKind::Jsr
);
assert_eq!(
resolve_registry_kind(None, Ecosystem::Dart).expect("pub registry:"),
RegistryKind::PubDev
);
let error = resolve_registry_kind(
Some(&PublishRegistry::Custom("internal".to_string())),
Ecosystem::Npm,
)
.expect_err("expected custom registry error");
assert!(
error
.to_string()
.contains("does not support custom registry `internal`")
);
let unsupported = default_registry_kind_for_ecosystem("ruby")
.expect_err("expected unsupported ecosystem error");
assert!(
unsupported
.to_string()
.contains("does not support ecosystem `ruby`")
);
}
#[test]
fn build_placeholder_requests_skip_missing_or_disabled_packages_and_report_errors() {
let root = tempfile::tempdir().expect("tempdir");
fs::write(
root.path().join("monochange.toml"),
"[package.pkg]\npath = \"packages/pkg\"\ntype = \"npm\"\n",
)
.expect("write config");
fs::create_dir_all(root.path().join("packages/pkg")).expect("mkdir");
fs::write(
root.path().join("packages/pkg/package.json"),
r#"{ "name": "pkg", "version": "1.0.0" }"#,
)
.expect("write package.json");
let mut configuration =
crate::load_workspace_configuration(root.path()).expect("configuration");
let package = PackageRecord {
id: "pkg".to_string(),
name: "pkg".to_string(),
ecosystem: Ecosystem::Npm,
manifest_path: root.path().join("packages/pkg/package.json"),
workspace_root: root.path().to_path_buf(),
current_version: Some(Version::parse("1.0.0").expect("version")),
publish_state: PublishState::Public,
version_group_id: None,
metadata: BTreeMap::from([
("config_id".to_string(), "pkg".to_string()),
("manager".to_string(), "pnpm".to_string()),
]),
declared_dependencies: Vec::new(),
};
let mut disabled = configuration.clone();
disabled.packages[0].publish.enabled = false;
disabled.packages.push(monochange_core::PackageDefinition {
id: "missing".to_string(),
..configuration.packages[0].clone()
});
let requests =
build_placeholder_requests(root.path(), &disabled, &[package.clone()], &BTreeSet::new())
.expect("requests");
assert!(requests.is_empty());
let requests = build_placeholder_requests(
root.path(),
&configuration,
&[package.clone()],
&BTreeSet::new(),
)
.expect("requests");
assert_eq!(requests[0].package_manager.as_deref(), Some("pnpm"));
let selected = build_placeholder_requests(
root.path(),
&configuration,
&[package.clone()],
&BTreeSet::from(["pkg".to_string()]),
)
.expect("selected requests");
assert_eq!(selected.len(), 1);
configuration.packages[0].publish.registry =
Some(PublishRegistry::Custom("internal".to_string()));
let registry_error = build_placeholder_requests(
root.path(),
&configuration,
&[package.clone()],
&BTreeSet::new(),
)
.expect_err("expected registry error");
assert!(registry_error.to_string().contains("custom registry"));
let mut missing_readme =
crate::load_workspace_configuration(root.path()).expect("configuration");
missing_readme.packages[0].publish.placeholder.readme_file = Some(PathBuf::from("missing.md"));
let readme_error =
build_placeholder_requests(root.path(), &missing_readme, &[package], &BTreeSet::new())
.expect_err("expected readme error");
assert!(
readme_error
.to_string()
.contains("failed to read placeholder README")
);
}
#[test]
fn build_publish_command_covers_all_supported_registries() {
let tempdir = tempfile::tempdir().expect("tempdir:");
let npm_placeholder = build_publish_command(
&sample_request(RegistryKind::Npm),
PackagePublishRunMode::Placeholder,
Some(tempdir.path()),
false,
);
assert_eq!(
npm_placeholder.args,
vec![
"publish".to_string(),
tempdir.path().display().to_string(),
"--access".to_string(),
"public".to_string(),
]
);
let npm = build_publish_command(
&sample_request(RegistryKind::Npm),
PackagePublishRunMode::Release,
None,
false,
);
assert_eq!(npm.program, "npm");
let pnpm_request = PublishRequest {
package_manager: Some("pnpm".to_string()),
..sample_request(RegistryKind::Npm)
};
let pnpm_placeholder = build_publish_command(
&pnpm_request,
PackagePublishRunMode::Placeholder,
Some(tempdir.path()),
false,
);
assert_eq!(pnpm_placeholder.program, "pnpm");
let pnpm = build_publish_command(&pnpm_request, PackagePublishRunMode::Release, None, false);
assert_eq!(pnpm.program, "pnpm");
let trusted_pnpm_request = PublishRequest {
trusted_publishing: TrustedPublishingSettings {
enabled: true,
repository: None,
workflow: None,
environment: None,
},
..pnpm_request
};
let trusted_pnpm = build_publish_command(
&trusted_pnpm_request,
PackagePublishRunMode::Release,
None,
false,
);
assert_eq!(trusted_pnpm.program, "npm");
let cargo_placeholder = build_publish_command(
&sample_request(RegistryKind::CratesIo),
PackagePublishRunMode::Placeholder,
Some(tempdir.path()),
false,
);
assert_eq!(cargo_placeholder.program, "cargo");
assert!(
cargo_placeholder
.args
.contains(&tempdir.path().join("Cargo.toml").display().to_string())
);
let cargo = build_publish_command(
&sample_request(RegistryKind::CratesIo),
PackagePublishRunMode::Release,
None,
false,
);
assert_eq!(cargo.program, "cargo");
let dart = build_publish_command(
&sample_request(RegistryKind::PubDev),
PackagePublishRunMode::Release,
None,
false,
);
assert_eq!(dart.program, "dart");
let dart_placeholder = build_publish_command(
&sample_request(RegistryKind::PubDev),
PackagePublishRunMode::Placeholder,
Some(tempdir.path()),
false,
);
assert_eq!(dart_placeholder.cwd, tempdir.path());
let flutter = build_publish_command(
&PublishRequest {
ecosystem: Ecosystem::Dart,
package_metadata: BTreeMap::from([("is_flutter".to_string(), "true".to_string())]),
..sample_request(RegistryKind::PubDev)
},
PackagePublishRunMode::Release,
None,
false,
);
assert_eq!(flutter.program, "flutter");
let pure_dart = build_publish_command(
&PublishRequest {
ecosystem: Ecosystem::Dart,
..sample_request(RegistryKind::PubDev)
},
PackagePublishRunMode::Release,
None,
false,
);
assert_eq!(pure_dart.program, "dart");
let jsr = build_publish_command(
&sample_request(RegistryKind::Jsr),
PackagePublishRunMode::Placeholder,
Some(tempdir.path()),
false,
);
assert_eq!(jsr.program, "deno");
assert_eq!(jsr.cwd, tempdir.path());
let jsr_release = build_publish_command(
&sample_request(RegistryKind::Jsr),
PackagePublishRunMode::Release,
None,
false,
);
assert_eq!(jsr_release.cwd, PathBuf::from("/workspace/pkg"));
let pypi_placeholder = build_publish_command(
&sample_request(RegistryKind::Pypi),
PackagePublishRunMode::Placeholder,
Some(tempdir.path()),
false,
);
assert_eq!(pypi_placeholder.program, "sh");
assert_eq!(pypi_placeholder.cwd, tempdir.path());
assert!(render_command(&pypi_placeholder).contains("uv publish --trusted-publishing never"));
let pypi_release_request = PublishRequest {
trusted_publishing: TrustedPublishingSettings {
enabled: true,
..TrustedPublishingSettings::default()
},
..sample_request(RegistryKind::Pypi)
};
let pypi_release = build_publish_command(
&pypi_release_request,
PackagePublishRunMode::Release,
None,
false,
);
assert_eq!(pypi_release.cwd, PathBuf::from("/workspace/pkg"));
assert!(render_command(&pypi_release).contains("uv publish --trusted-publishing always"));
let go_request = PublishRequest {
ecosystem: Ecosystem::Go,
package_name: "api".to_string(),
package_root: PathBuf::from("/workspace/api"),
package_metadata: BTreeMap::from([
(
"module_path".to_string(),
"github.com/example/api".to_string(),
),
("relative_path".to_string(), "api".to_string()),
]),
..sample_request(RegistryKind::GoProxy)
};
let go = build_publish_command(&go_request, PackagePublishRunMode::Release, None, false);
assert_eq!(go.program, "git");
assert_eq!(go.args, vec!["tag".to_string(), "api/v1.2.3".to_string()]);
}
#[test]
fn go_publish_command_uses_root_tag_when_relative_path_is_current_directory() {
let request = PublishRequest {
version: "v2.0.0".to_string(),
package_metadata: BTreeMap::from([("relative_path".to_string(), ".".to_string())]),
..sample_request(RegistryKind::GoProxy)
};
let command = build_publish_command(&request, PackagePublishRunMode::Release, None, false);
assert_eq!(command.args, vec!["tag".to_string(), "v2.0.0".to_string()]);
}
#[test]
fn go_publish_command_falls_back_to_package_root_prefix() {
let cwd = env::current_dir().expect("current dir");
let request = PublishRequest {
version: "1.2.3".to_string(),
package_root: cwd.join("api"),
package_metadata: BTreeMap::new(),
..sample_request(RegistryKind::GoProxy)
};
let command = build_publish_command(&request, PackagePublishRunMode::Release, None, false);
assert_eq!(
command.args,
vec!["tag".to_string(), "api/v1.2.3".to_string()]
);
}
#[test]
fn build_placeholder_directory_writes_go_mod_for_go_proxy() {
let root = tempfile::tempdir().expect("tempdir");
let request = PublishRequest {
package_metadata: BTreeMap::from([(
"module_path".to_string(),
"github.com/example/repo/api".to_string(),
)]),
..sample_request(RegistryKind::GoProxy)
};
let placeholder =
build_placeholder_directory(root.path(), &request, None).expect("go placeholder directory");
let go_mod = fs::read_to_string(placeholder.path().join("go.mod")).expect("go.mod");
assert_eq!(go_mod, "module github.com/example/repo/api\n\ngo 1.22\n");
}
#[test]
fn build_placeholder_directory_uses_package_name_when_go_module_metadata_is_missing() {
let root = tempfile::tempdir().expect("tempdir");
let request = PublishRequest {
package_name: "github.com/example/fallback".to_string(),
package_metadata: BTreeMap::new(),
..sample_request(RegistryKind::GoProxy)
};
let placeholder =
build_placeholder_directory(root.path(), &request, None).expect("go placeholder directory");
let go_mod = fs::read_to_string(placeholder.path().join("go.mod")).expect("go.mod");
assert_eq!(go_mod, "module github.com/example/fallback\n\ngo 1.22\n");
}
#[tokio::test(flavor = "multi_thread")]
async fn registry_version_exists_returns_false_for_missing_go_proxy_version() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET)
.path("/github.com/example/repo/@v/v1.2.3.info");
then.status(404);
});
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = sample_endpoints(&server.base_url());
let request = PublishRequest {
package_metadata: BTreeMap::from([(
"module_path".to_string(),
"github.com/example/repo".to_string(),
)]),
..sample_request(RegistryKind::GoProxy)
};
assert!(
!registry_version_exists(&client, &endpoints, &request)
.await
.expect("missing go version")
);
}
#[test]
fn build_publish_command_appends_dry_run_flags_for_supported_registries() {
let tempdir = tempfile::tempdir().expect("tempdir:");
let npm = build_publish_command(
&sample_request(RegistryKind::Npm),
PackagePublishRunMode::Placeholder,
Some(tempdir.path()),
true,
);
assert_eq!(npm.args.last(), Some(&"--dry-run".to_string()));
let cargo = build_publish_command(
&sample_request(RegistryKind::CratesIo),
PackagePublishRunMode::Placeholder,
Some(tempdir.path()),
true,
);
assert_eq!(cargo.args.last(), Some(&"--dry-run".to_string()));
let dart = build_publish_command(
&sample_request(RegistryKind::PubDev),
PackagePublishRunMode::Placeholder,
Some(tempdir.path()),
true,
);
assert!(dart.args.contains(&"--dry-run".to_string()));
assert!(!dart.args.contains(&"--force".to_string()));
let jsr = build_publish_command(
&sample_request(RegistryKind::Jsr),
PackagePublishRunMode::Placeholder,
Some(tempdir.path()),
true,
);
assert_eq!(jsr.args.last(), Some(&"--dry-run".to_string()));
let pypi = build_publish_command(
&sample_request(RegistryKind::Pypi),
PackagePublishRunMode::Placeholder,
Some(tempdir.path()),
true,
);
assert!(!render_command(&pypi).contains("--dry-run"));
let go = build_publish_command(
&sample_request(RegistryKind::GoProxy),
PackagePublishRunMode::Release,
None,
true,
);
assert!(!go.args.contains(&"--dry-run".to_string()));
}
#[test]
fn manual_setup_url_matches_each_registry() {
assert_eq!(
manual_setup_url(&sample_request(RegistryKind::Npm)),
"https://www.npmjs.com/package/pkg/access".to_string()
);
assert_eq!(
manual_setup_url(&sample_request(RegistryKind::CratesIo)),
"https://crates.io/crates/pkg".to_string()
);
assert_eq!(
manual_setup_url(&sample_request(RegistryKind::PubDev)),
"https://pub.dev/packages/pkg/admin".to_string()
);
assert_eq!(
manual_setup_url(&sample_request(RegistryKind::Jsr)),
"https://jsr.io/@scope/pkg".to_string()
);
assert_eq!(
manual_setup_url(&sample_request(RegistryKind::Pypi)),
"https://pypi.org/manage/project/pkg/settings/publishing/".to_string()
);
let go_request = PublishRequest {
package_name: "github.com/example/pkg".to_string(),
..sample_request(RegistryKind::GoProxy)
};
assert_eq!(
manual_setup_url(&go_request),
"https://pkg.go.dev/github.com/example/pkg".to_string()
);
assert_eq!(trust_docs_url(RegistryKind::Npm), NPM_TRUST_DOCS_URL);
assert_eq!(
trust_docs_url(RegistryKind::CratesIo),
CRATES_TRUST_DOCS_URL
);
assert_eq!(trust_docs_url(RegistryKind::PubDev), DART_TRUST_DOCS_URL);
assert_eq!(trust_docs_url(RegistryKind::Jsr), JSR_TRUST_DOCS_URL);
assert_eq!(trust_docs_url(RegistryKind::Pypi), PYPI_TRUST_DOCS_URL);
assert_eq!(
trust_docs_url(RegistryKind::GoProxy),
"https://go.dev/ref/mod#publishing"
);
}
#[test]
fn resolve_placeholder_readme_prefers_inline_then_file_then_default() {
let tempdir = tempfile::tempdir().expect("tempdir:");
fs::write(tempdir.path().join("README.md"), "from-file").expect("write readme:");
assert_eq!(
resolve_placeholder_readme(tempdir.path(), Some("inline"), None, "pkg").expect("inline:"),
"inline"
);
assert_eq!(
resolve_placeholder_readme(tempdir.path(), None, Some(Path::new("README.md")), "pkg")
.expect("file:"),
"from-file"
);
assert!(
resolve_placeholder_readme(tempdir.path(), None, None, "pkg")
.expect("default:")
.contains("placeholder release")
);
}
#[test]
fn resolve_placeholder_readme_reports_missing_file_errors() {
let tempdir = tempfile::tempdir().expect("tempdir:");
let error =
resolve_placeholder_readme(tempdir.path(), None, Some(Path::new("missing.md")), "pkg")
.expect_err("expected missing readme error");
assert!(
error
.to_string()
.contains("failed to read placeholder README")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn registry_version_exists_parses_all_supported_registry_shapes() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/pkg");
then.status(200).json_body_obj(&serde_json::json!({
"versions": {
"1.2.3": { "name": "pkg" }
}
}));
});
server.mock(|when, then| {
when.method(GET).path("/crates/pkg");
then.status(200).json_body_obj(&serde_json::json!({
"versions": [{ "num": "1.2.3" }]
}));
});
server.mock(|when, then| {
when.method(GET).path("/packages/pkg");
then.status(200).json_body_obj(&serde_json::json!({
"versions": [{ "version": "1.2.3" }]
}));
});
server.mock(|when, then| {
when.method(GET).path("/@scope/pkg/meta.json");
then.status(200).json_body_obj(&serde_json::json!({
"versions": { "1.2.3": {} }
}));
});
server.mock(|when, then| {
when.method(GET).path("/pkg/json");
then.status(200).json_body_obj(&serde_json::json!({
"releases": { "1.2.3": [] }
}));
});
server.mock(|when, then| {
when.method(GET)
.path("/github.com/example/!repo/api/@v/v1.2.3.info");
then.status(200).json_body_obj(&serde_json::json!({
"Version": "v1.2.3",
"Time": "2026-04-28T00:00:00Z"
}));
});
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = RegistryEndpoints {
npm_registry: server.base_url(),
crates_io_api: server.base_url(),
crates_io_index: server.base_url(),
pub_dev_api: server.base_url(),
jsr_base: server.base_url(),
pypi_api: server.base_url(),
go_proxy: server.base_url(),
};
assert!(
registry_version_exists(&client, &endpoints, &sample_request(RegistryKind::Npm))
.await
.expect("npm exists:")
);
assert!(
registry_version_exists(&client, &endpoints, &sample_request(RegistryKind::CratesIo))
.await
.expect("cargo exists:")
);
assert!(
registry_version_exists(&client, &endpoints, &sample_request(RegistryKind::PubDev))
.await
.expect("dart exists:")
);
assert!(
registry_version_exists(&client, &endpoints, &sample_request(RegistryKind::Jsr))
.await
.expect("jsr exists:")
);
assert!(
registry_version_exists(&client, &endpoints, &sample_request(RegistryKind::Pypi))
.await
.expect("PyPI exists:")
);
let go_request = PublishRequest {
package_metadata: BTreeMap::from([(
"module_path".to_string(),
"github.com/example/Repo/api".to_string(),
)]),
..sample_request(RegistryKind::GoProxy)
};
assert!(
registry_version_exists(&client, &endpoints, &go_request)
.await
.expect("Go exists:")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn registry_version_exists_treats_any_existing_version_as_placeholder_bootstrap() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/pkg");
then.status(200).json_body_obj(&serde_json::json!({
"versions": {
"1.0.0": { "name": "pkg" }
}
}));
});
server.mock(|when, then| {
when.method(GET).path("/crates/pkg");
then.status(200).json_body_obj(&serde_json::json!({
"versions": [{ "num": "1.0.0" }]
}));
});
server.mock(|when, then| {
when.method(GET).path("/packages/pkg");
then.status(200).json_body_obj(&serde_json::json!({
"versions": [{ "version": "1.0.0" }]
}));
});
server.mock(|when, then| {
when.method(GET).path("/@scope/pkg/meta.json");
then.status(200).json_body_obj(&serde_json::json!({
"versions": { "1.0.0": {} }
}));
});
server.mock(|when, then| {
when.method(GET).path("/pkg/json");
then.status(200).json_body_obj(&serde_json::json!({
"releases": { "1.0.0": [] }
}));
});
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = sample_endpoints(&server.base_url());
let placeholder = |registry| {
PublishRequest {
version: PLACEHOLDER_VERSION.to_string(),
placeholder: true,
..sample_request(registry)
}
};
assert!(
registry_version_exists(&client, &endpoints, &placeholder(RegistryKind::Npm))
.await
.expect("npm placeholder exists:")
);
assert!(
registry_version_exists(&client, &endpoints, &placeholder(RegistryKind::CratesIo))
.await
.expect("cargo placeholder exists:")
);
assert!(
registry_version_exists(&client, &endpoints, &placeholder(RegistryKind::PubDev))
.await
.expect("pub.dev placeholder exists:")
);
assert!(
registry_version_exists(&client, &endpoints, &placeholder(RegistryKind::Jsr))
.await
.expect("jsr placeholder exists:")
);
assert!(
registry_version_exists(&client, &endpoints, &placeholder(RegistryKind::Pypi))
.await
.expect("PyPI placeholder exists:")
);
}
#[test]
fn crates_io_index_entry_path_covers_sparse_layout_rules() {
assert_eq!(crates_io_index_entry_path(""), "");
assert_eq!(crates_io_index_entry_path("a"), "1/a");
assert_eq!(crates_io_index_entry_path("ab"), "2/ab");
assert_eq!(crates_io_index_entry_path("pkg"), "3/p/pkg");
assert_eq!(crates_io_index_entry_path("Crate"), "cr/at/crate");
}
#[tokio::test(flavor = "multi_thread")]
async fn registry_version_exists_falls_back_to_crates_io_index_when_api_is_forbidden() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/crates/pkg");
then.status(403);
});
server.mock(|when, then| {
when.method(GET).path("/3/p/pkg");
then.status(200)
.body("{\"name\":\"pkg\",\"vers\":\"1.2.3\"}\n");
});
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = sample_endpoints(&server.base_url());
assert!(
registry_version_exists(&client, &endpoints, &sample_request(RegistryKind::CratesIo))
.await
.expect("crates.io fallback exists:")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn registry_version_exists_reports_crates_io_index_fallback_failures() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/crates/pkg");
then.status(403);
});
server.mock(|when, then| {
when.method(GET).path("/3/p/pkg");
then.status(500);
});
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = sample_endpoints(&server.base_url());
let error =
registry_version_exists(&client, &endpoints, &sample_request(RegistryKind::CratesIo))
.await
.expect_err("expected crates.io fallback error");
assert!(
error
.to_string()
.contains("crates.io lookup failed with http status 403 Forbidden")
);
assert!(
error
.to_string()
.contains("crates.io index fallback failed")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn crates_io_index_version_exists_handles_missing_invalid_and_nonmatching_entries() {
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let missing_server = MockServer::start();
missing_server.mock(|when, then| {
when.method(GET).path("/3/p/pkg");
then.status(404);
});
assert!(
!crates_io_index_version_exists(
&client,
&sample_endpoints(&missing_server.base_url()),
&sample_request(RegistryKind::CratesIo),
)
.await
.expect("missing index entry:")
);
let invalid_server = MockServer::start();
invalid_server.mock(|when, then| {
when.method(GET).path("/3/p/pkg");
then.status(200).body("not-json\n");
});
let invalid_error = crates_io_index_version_exists(
&client,
&sample_endpoints(&invalid_server.base_url()),
&sample_request(RegistryKind::CratesIo),
)
.await
.expect_err("expected index decode error");
assert!(
invalid_error
.to_string()
.contains("crates.io index decode failed")
);
let nonmatching_server = MockServer::start();
nonmatching_server.mock(|when, then| {
when.method(GET).path("/3/p/pkg");
then.status(200)
.body("{\"name\":\"pkg\"}\n{\"name\":\"pkg\",\"vers\":\"9.9.9\"}\n");
});
assert!(
!crates_io_index_version_exists(
&client,
&sample_endpoints(&nonmatching_server.base_url()),
&sample_request(RegistryKind::CratesIo),
)
.await
.expect("nonmatching index entry:")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn crates_io_index_version_exists_matches_placeholder_or_requested_version() {
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/3/p/pkg");
then.status(200)
.body("{\"name\":\"pkg\",\"vers\":\"1.0.0\"}\n{\"name\":\"pkg\",\"vers\":\"1.2.3\"}\n");
});
let endpoints = sample_endpoints(&server.base_url());
assert!(
crates_io_index_version_exists(
&client,
&endpoints,
&PublishRequest {
placeholder: true,
version: PLACEHOLDER_VERSION.to_string(),
..sample_request(RegistryKind::CratesIo)
},
)
.await
.expect("placeholder index entry:")
);
assert!(
crates_io_index_version_exists(
&client,
&endpoints,
&sample_request(RegistryKind::CratesIo),
)
.await
.expect("matching index entry:")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn registry_version_exists_returns_false_for_missing_packages() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/missing");
then.status(404);
});
server.mock(|when, then| {
when.method(GET).path("/missing/json");
then.status(404);
});
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = RegistryEndpoints {
npm_registry: server.base_url(),
crates_io_api: server.base_url(),
crates_io_index: server.base_url(),
pub_dev_api: server.base_url(),
jsr_base: server.base_url(),
pypi_api: server.base_url(),
go_proxy: server.base_url(),
};
let request = sample_request(RegistryKind::Npm);
let request = PublishRequest {
package_name: "missing".to_string(),
..request
};
assert!(
!registry_version_exists(&client, &endpoints, &request)
.await
.expect("missing:")
);
let pypi_request = PublishRequest {
package_name: "missing".to_string(),
..sample_request(RegistryKind::Pypi)
};
assert!(
!registry_version_exists(&client, &endpoints, &pypi_request)
.await
.expect("PyPI missing:")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn registry_version_exists_handles_missing_and_invalid_registry_responses() {
let server = MockServer::start();
for path in [
"/crates/missing",
"/packages/missing",
"/@scope/missing/meta.json",
] {
server.mock(|when, then| {
when.method(GET).path(path);
then.status(404);
});
}
for path in [
"/bad-json",
"/crates/bad-json",
"/packages/bad-json",
"/@scope/bad-json/meta.json",
] {
server.mock(|when, then| {
when.method(GET).path(path);
then.status(200).body("not-json");
});
}
server.mock(|when, then| {
when.method(GET).path("/boom");
then.status(500).body("boom");
});
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = sample_endpoints(&server.base_url());
assert!(
!registry_version_exists(
&client,
&endpoints,
&PublishRequest {
package_name: "missing".to_string(),
..sample_request(RegistryKind::CratesIo)
},
)
.await
.expect("crates missing:")
);
assert!(
!registry_version_exists(
&client,
&endpoints,
&PublishRequest {
package_name: "missing".to_string(),
..sample_request(RegistryKind::PubDev)
},
)
.await
.expect("pub missing:")
);
assert!(
!registry_version_exists(
&client,
&endpoints,
&PublishRequest {
package_name: "@scope/missing".to_string(),
..sample_request(RegistryKind::Jsr)
},
)
.await
.expect("jsr missing:")
);
let decode_error = registry_version_exists(
&client,
&endpoints,
&PublishRequest {
package_name: "bad-json".to_string(),
..sample_request(RegistryKind::Npm)
},
)
.await
.expect_err("expected npm decode error");
assert!(
decode_error
.to_string()
.contains("npm registry decode failed")
);
let http_error = registry_version_exists(
&client,
&endpoints,
&PublishRequest {
package_name: "boom".to_string(),
..sample_request(RegistryKind::Npm)
},
)
.await
.expect_err("expected npm http error");
assert!(
http_error
.to_string()
.contains("npm registry lookup failed")
);
}
#[test]
fn write_cargo_placeholder_manifest_requires_license_metadata() {
let tempdir = tempfile::tempdir().expect("tempdir:");
let package_root = tempdir.path().join("pkg");
fs::create_dir_all(&package_root).expect("mkdir:");
let manifest_path = package_root.join("Cargo.toml");
fs::write(
&manifest_path,
"[package]\nname = \"pkg\"\nversion = \"1.0.0\"\nedition = \"2021\"\n",
)
.expect("write manifest:");
let placeholder_dir = tempfile::tempdir().expect("tempdir:");
let request = PublishRequest {
manifest_path,
package_root,
..sample_request(RegistryKind::CratesIo)
};
let error = write_cargo_placeholder_manifest(
placeholder_dir.path(),
&request,
tempdir.path(),
Some(&sample_source()),
)
.expect_err("expected cargo placeholder error");
let text = error.to_string();
assert!(text.contains("license"), "{text}");
}
#[test]
fn write_cargo_placeholder_manifest_reads_workspace_license_metadata() {
let tempdir = tempfile::tempdir().expect("tempdir:");
fs::write(
tempdir.path().join("Cargo.toml"),
concat!(
"[workspace]\n",
"members = [\"pkg\"]\n\n",
"[workspace.package]\n",
"license = \"Unlicense\"\n",
),
)
.expect("write workspace manifest:");
let package_root = tempdir.path().join("pkg");
fs::create_dir_all(&package_root).expect("mkdir:");
let manifest_path = package_root.join("Cargo.toml");
fs::write(
&manifest_path,
concat!(
"[package]\n",
"name = \"pkg\"\n",
"version = \"1.0.0\"\n",
"license = { workspace = true }\n",
),
)
.expect("write manifest:");
let placeholder_dir = tempfile::tempdir().expect("tempdir:");
let request = PublishRequest {
manifest_path,
package_root,
..sample_request(RegistryKind::CratesIo)
};
write_cargo_placeholder_manifest(
placeholder_dir.path(),
&request,
tempdir.path(),
Some(&sample_source()),
)
.expect("cargo placeholder:");
let placeholder_manifest = fs::read_to_string(placeholder_dir.path().join("Cargo.toml"))
.expect("read placeholder manifest:");
assert!(placeholder_manifest.contains("license = \"Unlicense\""));
}
#[test]
fn write_cargo_placeholder_manifest_copies_license_file_and_repository() {
let tempdir = tempfile::tempdir().expect("tempdir:");
let package_root = tempdir.path().join("pkg");
fs::create_dir_all(&package_root).expect("mkdir:");
fs::write(package_root.join("LICENSE.md"), "MIT").expect("write license:");
let manifest_path = package_root.join("Cargo.toml");
fs::write(
&manifest_path,
concat!(
"[package]\n",
"name = \"pkg\"\n",
"version = \"1.0.0\"\n",
"edition = \"2024\"\n",
"description = \"crate placeholder\"\n",
"license-file = \"LICENSE.md\"\n",
),
)
.expect("write manifest:");
let placeholder_dir = tempfile::tempdir().expect("tempdir:");
let request = PublishRequest {
manifest_path,
package_root,
..sample_request(RegistryKind::CratesIo)
};
write_cargo_placeholder_manifest(
placeholder_dir.path(),
&request,
tempdir.path(),
Some(&sample_source()),
)
.expect("cargo placeholder:");
let placeholder_manifest = fs::read_to_string(placeholder_dir.path().join("Cargo.toml"))
.expect("read placeholder manifest:");
assert!(placeholder_manifest.contains("edition = \"2024\""));
assert!(placeholder_manifest.contains("license-file = \"LICENSE\""));
assert!(
placeholder_manifest.contains("repository = \"https://github.com/monochange/monochange\"")
);
assert_eq!(
fs::read_to_string(placeholder_dir.path().join("LICENSE"))
.expect("read placeholder license:"),
"MIT"
);
assert!(placeholder_dir.path().join("src/lib.rs").is_file());
}
#[test]
fn write_cargo_placeholder_manifest_reports_manifest_io_parse_and_copy_failures() {
let root = tempfile::tempdir().expect("tempdir");
let dir = tempfile::tempdir().expect("tempdir");
let missing_error = write_cargo_placeholder_manifest(
dir.path(),
&PublishRequest {
manifest_path: root.path().join("missing/Cargo.toml"),
package_root: PathBuf::from("missing"),
..sample_request(RegistryKind::CratesIo)
},
root.path(),
None,
)
.expect_err("expected missing manifest error");
assert!(
missing_error
.to_string()
.contains("failed to read Cargo manifest")
);
let invalid_manifest = root.path().join("invalid/Cargo.toml");
fs::create_dir_all(invalid_manifest.parent().expect("parent")).expect("mkdir");
fs::write(&invalid_manifest, "[package").expect("write invalid manifest");
let parse_error = write_cargo_placeholder_manifest(
dir.path(),
&PublishRequest {
manifest_path: invalid_manifest,
package_root: PathBuf::from("invalid"),
..sample_request(RegistryKind::CratesIo)
},
root.path(),
None,
)
.expect_err("expected parse error");
assert!(parse_error.to_string().contains("failed to parse"));
let missing_package_manifest = root.path().join("no-package/Cargo.toml");
fs::create_dir_all(missing_package_manifest.parent().expect("parent")).expect("mkdir");
fs::write(&missing_package_manifest, "[workspace]\nmembers = []\n")
.expect("write workspace manifest");
let missing_package_error = write_cargo_placeholder_manifest(
dir.path(),
&PublishRequest {
manifest_path: missing_package_manifest,
package_root: PathBuf::from("no-package"),
..sample_request(RegistryKind::CratesIo)
},
root.path(),
None,
)
.expect_err("expected missing package error");
assert!(
missing_package_error
.to_string()
.contains("is missing [package]")
);
let copy_manifest = root.path().join("copy/Cargo.toml");
fs::create_dir_all(copy_manifest.parent().expect("parent")).expect("mkdir");
fs::write(
©_manifest,
concat!(
"[package]\n",
"name = \"pkg\"\n",
"version = \"1.0.0\"\n",
"license-file = \"LICENSE.md\"\n",
),
)
.expect("write manifest");
let copy_error = write_cargo_placeholder_manifest(
dir.path(),
&PublishRequest {
manifest_path: copy_manifest,
package_root: PathBuf::from("copy"),
..sample_request(RegistryKind::CratesIo)
},
root.path(),
None,
)
.expect_err("expected copy error");
assert!(
copy_error
.to_string()
.contains("failed to copy placeholder license file")
);
}
#[test]
fn placeholder_manifest_writers_report_write_failures() {
let tempdir = tempfile::tempdir().expect("tempdir");
let file_root = tempdir.path().join("not-a-dir");
fs::write(&file_root, "file").expect("write file root");
let npm_error =
write_npm_placeholder_manifest(&file_root, &sample_request(RegistryKind::Npm), None)
.expect_err("expected npm write error");
assert!(
npm_error
.to_string()
.contains("failed to write placeholder package.json")
);
let dart_error =
write_dart_placeholder_manifest(&file_root, &sample_request(RegistryKind::PubDev), None)
.expect_err("expected dart write error");
assert!(
dart_error
.to_string()
.contains("failed to write placeholder pubspec.yaml")
);
let jsr_error =
write_jsr_placeholder_manifest(&file_root, &sample_request(RegistryKind::Jsr), None)
.expect_err("expected jsr write error");
assert!(
jsr_error
.to_string()
.contains("failed to write placeholder deno.json")
);
}
#[test]
fn cargo_and_jsr_placeholder_manifests_report_directory_write_failures() {
let tempdir = tempfile::tempdir().expect("tempdir");
let package_root = tempdir.path().join("pkg");
fs::create_dir_all(&package_root).expect("mkdir");
let manifest_path = package_root.join("Cargo.toml");
fs::write(
&manifest_path,
concat!(
"[package]\n",
"name = \"pkg\"\n",
"version = \"1.0.0\"\n",
"license = \"MIT\"\n",
),
)
.expect("write manifest");
let request = PublishRequest {
manifest_path,
package_root,
..sample_request(RegistryKind::CratesIo)
};
let file_root = tempdir.path().join("file-root");
fs::write(&file_root, "file").expect("write file root");
let mkdir_error = write_cargo_placeholder_manifest(&file_root, &request, tempdir.path(), None)
.expect_err("expected src dir error");
assert!(
mkdir_error
.to_string()
.contains("failed to create placeholder src directory")
);
let src_file_root = tempdir.path().join("src-file-root");
fs::create_dir_all(src_file_root.join("src/lib.rs")).expect("create lib.rs directory");
let src_write_error =
write_cargo_placeholder_manifest(&src_file_root, &request, tempdir.path(), None)
.expect_err("expected src file write error");
assert!(
src_write_error
.to_string()
.contains("failed to write placeholder src/lib.rs")
);
let manifest_file_root = tempdir.path().join("manifest-file-root");
fs::create_dir_all(manifest_file_root.join("src")).expect("create src");
fs::create_dir_all(manifest_file_root.join("Cargo.toml")).expect("create Cargo.toml directory");
let manifest_write_error =
write_cargo_placeholder_manifest(&manifest_file_root, &request, tempdir.path(), None)
.expect_err("expected cargo manifest write error");
assert!(
manifest_write_error
.to_string()
.contains("failed to write placeholder Cargo.toml")
);
let jsr_mod_root = tempdir.path().join("jsr-mod-root");
fs::create_dir_all(&jsr_mod_root).expect("mkdir jsr root");
fs::create_dir_all(jsr_mod_root.join("mod.ts")).expect("create mod.ts directory");
let jsr_mod_error =
write_jsr_placeholder_manifest(&jsr_mod_root, &sample_request(RegistryKind::Jsr), None)
.expect_err("expected mod.ts write error");
assert!(
jsr_mod_error
.to_string()
.contains("failed to write placeholder mod.ts")
);
}
fn sample_npm_package_with_dependencies(
id: &str,
name: &str,
declared_dependencies: Vec<monochange_core::PackageDependency>,
) -> PackageRecord {
PackageRecord {
id: format!("npm:packages/{id}/package.json"),
name: name.to_string(),
ecosystem: Ecosystem::Npm,
manifest_path: PathBuf::from(format!("/workspace/packages/{id}/package.json")),
workspace_root: PathBuf::from("/workspace"),
current_version: Some(Version::parse("1.0.0").expect("version")),
publish_state: PublishState::Public,
version_group_id: None,
metadata: BTreeMap::from([
("config_id".to_string(), id.to_string()),
("manager".to_string(), "pnpm".to_string()),
]),
declared_dependencies,
}
}
fn sample_npm_dependency(name: &str, kind: DependencyKind) -> monochange_core::PackageDependency {
monochange_core::PackageDependency {
name: name.to_string(),
kind,
version_constraint: Some("workspace:*".to_string()),
optional: false,
source_field: Some(
match kind {
DependencyKind::Development => "devDependencies",
DependencyKind::Peer => "peerDependencies",
_ => "dependencies",
}
.to_string(),
),
}
}
fn sample_npm_publication(package: &str) -> PackagePublicationTarget {
PackagePublicationTarget {
package: package.to_string(),
ecosystem: Ecosystem::Npm,
registry: Some(PublishRegistry::Builtin(RegistryKind::Npm)),
version: "1.2.3".to_string(),
mode: PublishMode::Builtin,
trusted_publishing: TrustedPublishingSettings::default(),
attestations: PublishAttestationSettings::default(),
}
}
#[test]
fn build_release_requests_orders_publish_relevant_dependencies_before_dependents() {
let configuration = sample_configuration(&[
("app", monochange_core::PackageType::Npm, true),
("core", monochange_core::PackageType::Npm, true),
("utils", monochange_core::PackageType::Npm, true),
]);
let packages = vec![
sample_npm_package_with_dependencies(
"app",
"app",
vec![
sample_npm_dependency("core", DependencyKind::Runtime),
sample_npm_dependency("utils", DependencyKind::Build),
],
),
sample_npm_package_with_dependencies(
"utils",
"utils",
vec![sample_npm_dependency("core", DependencyKind::Peer)],
),
sample_npm_package_with_dependencies("core", "core", Vec::new()),
];
let publications = vec![
sample_npm_publication("app"),
sample_npm_publication("utils"),
sample_npm_publication("core"),
];
let requests =
build_release_requests(&configuration, &packages, &publications, &BTreeSet::new())
.expect("requests");
let ordered_package_ids = requests
.iter()
.map(|request| request.package_id.as_str())
.collect::<Vec<_>>();
assert_eq!(ordered_package_ids, vec!["core", "utils", "app"]);
}
#[test]
fn build_release_requests_orders_large_interdependent_package_set_before_batching() {
let package_ids = (1..=50)
.map(|index| format!("pkg-{index:02}"))
.collect::<Vec<_>>();
let definitions = package_ids
.iter()
.map(|package_id| (package_id.as_str(), monochange_core::PackageType::Npm, true))
.collect::<Vec<_>>();
let configuration = sample_configuration(&definitions);
let packages = package_ids
.iter()
.enumerate()
.rev()
.map(|(index, package_id)| {
let dependencies = (0..index)
.rev()
.take(3)
.map(|dependency_index| {
sample_npm_dependency(&package_ids[dependency_index], DependencyKind::Runtime)
})
.collect::<Vec<_>>();
sample_npm_package_with_dependencies(package_id, package_id, dependencies)
})
.collect::<Vec<_>>();
let publications = package_ids
.iter()
.rev()
.map(|package_id| sample_npm_publication(package_id))
.collect::<Vec<_>>();
let requests =
build_release_requests(&configuration, &packages, &publications, &BTreeSet::new())
.expect("requests");
let ordered_package_ids = requests
.iter()
.map(|request| request.package_id.as_str())
.collect::<Vec<_>>();
assert_eq!(ordered_package_ids, package_ids);
}
#[test]
fn build_release_requests_ignores_dependencies_outside_selected_publications() {
let configuration = sample_configuration(&[
("app", monochange_core::PackageType::Npm, true),
("core", monochange_core::PackageType::Npm, true),
]);
let packages = vec![
sample_npm_package_with_dependencies(
"app",
"app",
vec![sample_npm_dependency("core", DependencyKind::Runtime)],
),
sample_npm_package_with_dependencies("core", "core", Vec::new()),
];
let publications = vec![sample_npm_publication("app")];
let requests =
build_release_requests(&configuration, &packages, &publications, &BTreeSet::new())
.expect("dependency outside publication set should not block publishing");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].package_id, "app");
}
#[test]
fn build_release_requests_detects_publish_relevant_dependency_cycles() {
let configuration = sample_configuration(&[
("core", monochange_core::PackageType::Npm, true),
("utils", monochange_core::PackageType::Npm, true),
]);
let packages = vec![
sample_npm_package_with_dependencies(
"core",
"core",
vec![sample_npm_dependency("utils", DependencyKind::Runtime)],
),
sample_npm_package_with_dependencies(
"utils",
"utils",
vec![sample_npm_dependency("core", DependencyKind::Workspace)],
),
];
let publications = vec![
sample_npm_publication("core"),
sample_npm_publication("utils"),
];
let error = build_release_requests(&configuration, &packages, &publications, &BTreeSet::new())
.expect_err("publish-relevant dependency cycles should fail");
let message = error.to_string();
assert!(message.contains("cyclic publish dependencies"));
assert!(message.contains("core -> utils"));
assert!(message.contains("utils -> core"));
}
#[test]
fn build_release_requests_reports_development_dependency_cycles() {
let configuration = sample_configuration(&[
("core", monochange_core::PackageType::Npm, true),
("utils", monochange_core::PackageType::Npm, true),
]);
let packages = vec![
sample_npm_package_with_dependencies(
"core",
"core",
vec![sample_npm_dependency("utils", DependencyKind::Development)],
),
sample_npm_package_with_dependencies(
"utils",
"utils",
vec![sample_npm_dependency("core", DependencyKind::Development)],
),
];
let publications = vec![
sample_npm_publication("utils"),
sample_npm_publication("core"),
];
let error = build_release_requests(&configuration, &packages, &publications, &BTreeSet::new())
.expect_err("development dependency cycles should fail");
let message = error.to_string();
assert!(message.contains("cyclic publish dependencies detected"));
assert!(message.contains("core -> utils"));
assert!(message.contains("utils -> core"));
}
#[test]
fn build_release_requests_skips_unknown_publication_targets() {
let package = PackageRecord {
id: "npm:packages/pkg/package.json".to_string(),
name: "pkg".to_string(),
ecosystem: Ecosystem::Npm,
manifest_path: PathBuf::from("/workspace/packages/pkg/package.json"),
workspace_root: PathBuf::from("/workspace"),
current_version: Some(Version::parse("1.0.0").expect("version")),
publish_state: PublishState::Public,
version_group_id: None,
metadata: BTreeMap::from([
("config_id".to_string(), "pkg".to_string()),
("manager".to_string(), "pnpm".to_string()),
]),
declared_dependencies: Vec::new(),
};
let publications = vec![
PackagePublicationTarget {
package: "missing".to_string(),
ecosystem: Ecosystem::Npm,
registry: Some(PublishRegistry::Builtin(RegistryKind::Npm)),
version: "1.0.0".to_string(),
mode: PublishMode::Builtin,
trusted_publishing: TrustedPublishingSettings::default(),
attestations: PublishAttestationSettings::default(),
},
PackagePublicationTarget {
package: "pkg".to_string(),
ecosystem: Ecosystem::Npm,
registry: Some(PublishRegistry::Builtin(RegistryKind::Npm)),
version: "1.2.3".to_string(),
mode: PublishMode::Builtin,
trusted_publishing: TrustedPublishingSettings::default(),
attestations: PublishAttestationSettings::default(),
},
];
let configuration = sample_configuration(&[("pkg", monochange_core::PackageType::Npm, true)]);
let requests = build_release_requests(
&configuration,
&[package.clone()],
&publications,
&BTreeSet::new(),
)
.expect("requests");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].package_id, "pkg");
assert_eq!(requests[0].package_manager.as_deref(), Some("pnpm"));
let filtered = build_release_requests(
&configuration,
&[package],
&publications,
&BTreeSet::from(["missing".to_string()]),
)
.expect("filtered requests");
assert!(filtered.is_empty());
}
#[test]
fn build_release_requests_skips_publication_targets_missing_from_discovery() {
let configuration = sample_configuration(&[("pkg", monochange_core::PackageType::Npm, true)]);
let publications = vec![PackagePublicationTarget {
package: "pkg".to_string(),
ecosystem: Ecosystem::Npm,
registry: Some(PublishRegistry::Builtin(RegistryKind::Npm)),
version: "1.2.3".to_string(),
mode: PublishMode::Builtin,
trusted_publishing: TrustedPublishingSettings::default(),
attestations: PublishAttestationSettings::default(),
}];
let requests = build_release_requests(&configuration, &[], &publications, &BTreeSet::new())
.expect("requests");
assert!(requests.is_empty());
}
#[test]
fn build_release_requests_skips_disabled_and_private_packages() {
let configuration = sample_configuration(&[
("public", monochange_core::PackageType::Npm, true),
("disabled", monochange_core::PackageType::Npm, false),
("private", monochange_core::PackageType::Cargo, true),
]);
let packages = vec![
PackageRecord {
id: "npm:packages/public/package.json".to_string(),
name: "public".to_string(),
ecosystem: Ecosystem::Npm,
manifest_path: PathBuf::from("/workspace/packages/public/package.json"),
workspace_root: PathBuf::from("/workspace"),
current_version: Some(Version::parse("1.0.0").expect("version")),
publish_state: PublishState::Public,
version_group_id: None,
metadata: BTreeMap::from([("config_id".to_string(), "public".to_string())]),
declared_dependencies: Vec::new(),
},
PackageRecord {
id: "npm:packages/disabled/package.json".to_string(),
name: "disabled".to_string(),
ecosystem: Ecosystem::Npm,
manifest_path: PathBuf::from("/workspace/packages/disabled/package.json"),
workspace_root: PathBuf::from("/workspace"),
current_version: Some(Version::parse("1.0.0").expect("version")),
publish_state: PublishState::Public,
version_group_id: None,
metadata: BTreeMap::from([("config_id".to_string(), "disabled".to_string())]),
declared_dependencies: Vec::new(),
},
PackageRecord {
id: "cargo:crates/private/Cargo.toml".to_string(),
name: "private".to_string(),
ecosystem: Ecosystem::Cargo,
manifest_path: PathBuf::from("/workspace/crates/private/Cargo.toml"),
workspace_root: PathBuf::from("/workspace"),
current_version: Some(Version::parse("1.0.0").expect("version")),
publish_state: PublishState::Private,
version_group_id: None,
metadata: BTreeMap::from([("config_id".to_string(), "private".to_string())]),
declared_dependencies: Vec::new(),
},
];
let publications = vec![
PackagePublicationTarget {
package: "public".to_string(),
ecosystem: Ecosystem::Npm,
registry: Some(PublishRegistry::Builtin(RegistryKind::Npm)),
version: "1.0.1".to_string(),
mode: PublishMode::Builtin,
trusted_publishing: TrustedPublishingSettings::default(),
attestations: PublishAttestationSettings::default(),
},
PackagePublicationTarget {
package: "disabled".to_string(),
ecosystem: Ecosystem::Npm,
registry: Some(PublishRegistry::Builtin(RegistryKind::Npm)),
version: "1.0.1".to_string(),
mode: PublishMode::Builtin,
trusted_publishing: TrustedPublishingSettings::default(),
attestations: PublishAttestationSettings::default(),
},
PackagePublicationTarget {
package: "private".to_string(),
ecosystem: Ecosystem::Cargo,
registry: Some(PublishRegistry::Builtin(RegistryKind::CratesIo)),
version: "1.0.1".to_string(),
mode: PublishMode::Builtin,
trusted_publishing: TrustedPublishingSettings::default(),
attestations: PublishAttestationSettings::default(),
},
];
let requests =
build_release_requests(&configuration, &packages, &publications, &BTreeSet::new())
.expect("requests");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].package_id, "public");
}
#[test]
fn write_placeholder_directory_builds_npm_jsr_dart_and_python_scaffolds() {
let tempdir = tempfile::tempdir().expect("tempdir:");
let npm = build_placeholder_directory(
tempdir.path(),
&sample_request(RegistryKind::Npm),
Some(&sample_source()),
)
.expect("npm placeholder:");
assert!(npm.path().join("package.json").is_file());
let dart = build_placeholder_directory(
tempdir.path(),
&sample_request(RegistryKind::PubDev),
Some(&sample_source()),
)
.expect("dart placeholder:");
assert!(dart.path().join("pubspec.yaml").is_file());
let jsr = build_placeholder_directory(
tempdir.path(),
&sample_request(RegistryKind::Jsr),
Some(&sample_source()),
)
.expect("jsr placeholder:");
assert!(jsr.path().join("deno.json").is_file());
let python_request = PublishRequest {
package_name: "Example-Pkg.Name".to_string(),
..sample_request(RegistryKind::Pypi)
};
let python =
build_placeholder_directory(tempdir.path(), &python_request, Some(&sample_source()))
.expect("Python placeholder:");
let pyproject =
fs::read_to_string(python.path().join("pyproject.toml")).expect("read pyproject.toml");
assert!(pyproject.contains("name = \"Example-Pkg.Name\""));
assert!(pyproject.contains("packages = [\"src/example_pkg_name\"]"));
assert!(
python
.path()
.join("src")
.join("example_pkg_name")
.join("__init__.py")
.is_file()
);
let digit_request = PublishRequest {
package_name: "123-pkg".to_string(),
..sample_request(RegistryKind::Pypi)
};
let digit_python = build_placeholder_directory(tempdir.path(), &digit_request, None)
.expect("digit Python placeholder:");
assert!(
digit_python
.path()
.join("src")
.join("placeholder_123_pkg")
.join("__init__.py")
.is_file()
);
}
#[test]
fn python_placeholder_manifest_writers_report_io_errors() {
let request = sample_request(RegistryKind::Pypi);
let tempdir = tempfile::tempdir().expect("tempdir:");
fs::create_dir(tempdir.path().join("pyproject.toml")).expect("create pyproject dir");
let error = write_python_placeholder_manifest(tempdir.path(), &request, None)
.expect_err("pyproject write should fail");
assert!(
error
.to_string()
.contains("failed to write placeholder pyproject.toml")
);
let tempdir = tempfile::tempdir().expect("tempdir:");
fs::write(tempdir.path().join("src"), "not a directory").expect("write src file");
let error = write_python_placeholder_manifest(tempdir.path(), &request, None)
.expect_err("package directory create should fail");
assert!(
error
.to_string()
.contains("failed to create placeholder Python package")
);
let tempdir = tempfile::tempdir().expect("tempdir:");
let module_dir = tempdir.path().join("src").join("pkg");
fs::create_dir_all(&module_dir).expect("create module dir");
fs::create_dir(module_dir.join("__init__.py")).expect("create init dir");
let error = write_python_placeholder_manifest(tempdir.path(), &request, None)
.expect_err("module write should fail");
assert!(
error
.to_string()
.contains("failed to write placeholder Python package module")
);
}
#[test]
fn placeholder_tempdir_error_renders_stable_message() {
let error = placeholder_tempdir_error(&std::io::Error::other("disk full"));
assert_eq!(
error.to_string(),
"io error: failed to create placeholder tempdir: disk full"
);
}
#[test]
fn placeholder_directory_manifests_include_expected_repository_metadata() {
let tempdir = tempfile::tempdir().expect("tempdir:");
let npm = build_placeholder_directory(
tempdir.path(),
&sample_request(RegistryKind::Npm),
Some(&sample_source()),
)
.expect("npm placeholder:");
let npm_manifest =
fs::read_to_string(npm.path().join("package.json")).expect("read package.json:");
let npm_manifest_json =
serde_json::from_str::<JsonValue>(&npm_manifest).expect("parse package.json");
let npm_repository = npm_manifest_json
.get("repository")
.and_then(JsonValue::as_str);
assert_eq!(
npm_repository,
Some("https://github.com/monochange/monochange")
);
let dart = build_placeholder_directory(
tempdir.path(),
&sample_request(RegistryKind::PubDev),
Some(&sample_source()),
)
.expect("dart placeholder:");
let pubspec = fs::read_to_string(dart.path().join("pubspec.yaml")).expect("read pubspec.yaml:");
assert!(pubspec.contains("repository: https://github.com/monochange/monochange"));
let jsr = build_placeholder_directory(
tempdir.path(),
&sample_request(RegistryKind::Jsr),
Some(&sample_source()),
)
.expect("jsr placeholder:");
let deno_manifest = fs::read_to_string(jsr.path().join("deno.json")).expect("read deno.json:");
let deno_manifest_json =
serde_json::from_str::<JsonValue>(&deno_manifest).expect("parse deno.json");
let deno_repository = deno_manifest_json
.get("repository")
.and_then(JsonValue::as_str);
assert_eq!(
deno_repository,
Some("https://github.com/monochange/monochange")
);
}
#[test]
fn planned_and_skip_trust_outcomes_cover_npm_and_manual_flows() {
let root = workflow_root();
let env_map = BTreeMap::from([
(
"GITHUB_REPOSITORY".to_string(),
"monochange/monochange".to_string(),
),
(
"GITHUB_WORKFLOW_REF".to_string(),
"monochange/monochange/.github/workflows/publish.yml@refs/heads/main".to_string(),
),
("GITHUB_JOB".to_string(), "release".to_string()),
]);
let planned = planned_trust_outcome(
&trusted_request(RegistryKind::Npm),
Some(&sample_source()),
root.path(),
&env_map,
);
assert_eq!(planned.status, TrustedPublishingStatus::Planned);
assert_eq!(planned.environment, Some("publisher".to_string()));
assert_eq!(
planned.setup_url.as_deref(),
Some("https://www.npmjs.com/package/pkg/access")
);
let skipped = trust_outcome_for_skip(
&trusted_request(RegistryKind::Npm),
Some(&sample_source()),
root.path(),
&env_map,
);
assert_eq!(skipped.status, TrustedPublishingStatus::Configured);
assert_eq!(
skipped.setup_url.as_deref(),
Some("https://www.npmjs.com/package/pkg/access")
);
let manual = planned_trust_outcome(
&trusted_request(RegistryKind::CratesIo),
Some(&sample_source()),
root.path(),
&env_map,
);
assert_eq!(manual.status, TrustedPublishingStatus::ManualActionRequired);
assert_eq!(manual.repository.as_deref(), Some("monochange/monochange"));
assert_eq!(manual.workflow.as_deref(), Some("publish.yml"));
assert_eq!(manual.environment.as_deref(), Some("publisher"));
assert!(
manual
.setup_url
.expect("expected setup url")
.contains("crates.io/crates/pkg")
);
}
#[test]
fn configure_npm_trusted_publishing_skips_when_trust_list_matches_context() {
let root = workflow_root();
let env_map = BTreeMap::from([
(
"GITHUB_REPOSITORY".to_string(),
"monochange/monochange".to_string(),
),
(
"GITHUB_WORKFLOW_REF".to_string(),
"monochange/monochange/.github/workflows/publish.yml@refs/heads/main".to_string(),
),
("GITHUB_JOB".to_string(), "release".to_string()),
]);
let request = trusted_request(RegistryKind::Npm);
let mut executor = FakeExecutor::new(vec![CommandOutput {
success: true,
stdout: "repository monochange/monochange workflow publish.yml environment publisher"
.to_string(),
stderr: String::new(),
}]);
let outcome = configure_npm_trusted_publishing(
&request,
Some(&sample_source()),
root.path(),
&env_map,
&mut executor,
)
.expect("configure npm trusted publishing");
assert_eq!(outcome.status, TrustedPublishingStatus::Configured);
assert_eq!(
outcome.message,
"npm trusted publishing already matches the current GitHub workflow"
);
assert_eq!(executor.commands.len(), 1);
assert_eq!(executor.commands[0].program, "npm");
assert_eq!(executor.commands[0].args, ["trust", "ls", "pkg"]);
assert!(executor.commands[0].cwd.as_os_str().is_empty());
}
#[test]
fn configure_npm_trusted_publishing_runs_trust_command_and_verifies_result() {
let root = workflow_root();
let env_map = BTreeMap::from([
(
"GITHUB_REPOSITORY".to_string(),
"monochange/monochange".to_string(),
),
(
"GITHUB_WORKFLOW_REF".to_string(),
"monochange/monochange/.github/workflows/publish.yml@refs/heads/main".to_string(),
),
("GITHUB_JOB".to_string(), "release".to_string()),
]);
let request = trusted_request(RegistryKind::Npm);
let mut executor = FakeExecutor::new(vec![
CommandOutput {
success: true,
stdout: String::new(),
stderr: String::new(),
},
CommandOutput {
success: true,
stdout: String::new(),
stderr: String::new(),
},
CommandOutput {
success: true,
stdout: "repository monochange/monochange workflow publish.yml environment publisher"
.to_string(),
stderr: String::new(),
},
]);
let outcome = configure_npm_trusted_publishing(
&request,
Some(&sample_source()),
root.path(),
&env_map,
&mut executor,
)
.expect("configure npm trusted publishing");
assert_eq!(outcome.status, TrustedPublishingStatus::Configured);
assert_eq!(
outcome.message,
"configured npm trusted publishing for the current GitHub workflow"
);
assert_eq!(executor.commands.len(), 3);
assert_eq!(executor.commands[0].args, ["trust", "ls", "pkg"]);
assert_eq!(
executor.commands[1].args,
[
"trust",
"github",
"pkg",
"--file",
"publish.yml",
"--repo",
"monochange/monochange",
"--yes",
"--env",
"publisher",
]
);
assert_eq!(executor.commands[2].args, ["trust", "ls", "pkg"]);
}
#[test]
fn configure_npm_trusted_publishing_reports_trust_command_failure() {
let root = workflow_root();
let env_map = BTreeMap::from([
(
"GITHUB_REPOSITORY".to_string(),
"monochange/monochange".to_string(),
),
(
"GITHUB_WORKFLOW_REF".to_string(),
"monochange/monochange/.github/workflows/publish.yml@refs/heads/main".to_string(),
),
("GITHUB_JOB".to_string(), "release".to_string()),
]);
let request = trusted_request(RegistryKind::Npm);
let mut executor = FakeExecutor::new(vec![
CommandOutput {
success: true,
stdout: String::new(),
stderr: String::new(),
},
CommandOutput {
success: false,
stdout: String::new(),
stderr: "permission denied".to_string(),
},
]);
let error = configure_npm_trusted_publishing(
&request,
Some(&sample_source()),
root.path(),
&env_map,
&mut executor,
)
.expect_err("expected trust command failure");
let message = error.to_string();
assert!(message.contains("`npm trust github pkg"));
assert!(message.contains("permission denied"));
assert_eq!(executor.commands.len(), 2);
}
#[test]
fn configure_npm_trusted_publishing_reports_failed_verification() {
let root = workflow_root();
let env_map = BTreeMap::from([
(
"GITHUB_REPOSITORY".to_string(),
"monochange/monochange".to_string(),
),
(
"GITHUB_WORKFLOW_REF".to_string(),
"monochange/monochange/.github/workflows/publish.yml@refs/heads/main".to_string(),
),
("GITHUB_JOB".to_string(), "release".to_string()),
]);
let request = trusted_request(RegistryKind::Npm);
let mut executor = FakeExecutor::new(vec![
CommandOutput {
success: true,
stdout: String::new(),
stderr: String::new(),
},
CommandOutput {
success: true,
stdout: String::new(),
stderr: String::new(),
},
CommandOutput {
success: true,
stdout: "repository other/repo workflow publish.yml".to_string(),
stderr: String::new(),
},
]);
let error = configure_npm_trusted_publishing(
&request,
Some(&sample_source()),
root.path(),
&env_map,
&mut executor,
)
.expect_err("expected failed verification");
let message = error.to_string();
assert!(message.contains("npm trusted publishing could not be verified for `pkg`"));
assert!(message.contains("npm trust github pkg"));
assert_eq!(executor.commands.len(), 3);
}
#[test]
fn trust_outcome_for_skip_uses_manual_action_for_non_npm_packages() {
let root = workflow_root();
let env_map = BTreeMap::from([
(
"GITHUB_REPOSITORY".to_string(),
"monochange/monochange".to_string(),
),
(
"GITHUB_WORKFLOW_REF".to_string(),
"monochange/monochange/.github/workflows/publish.yml@refs/heads/main".to_string(),
),
("GITHUB_JOB".to_string(), "release".to_string()),
]);
let outcome = trust_outcome_for_skip(
&trusted_request(RegistryKind::CratesIo),
Some(&sample_source()),
root.path(),
&env_map,
);
assert_eq!(
outcome.status,
TrustedPublishingStatus::ManualActionRequired
);
assert_eq!(outcome.repository.as_deref(), Some("monochange/monochange"));
assert_eq!(outcome.workflow.as_deref(), Some("publish.yml"));
assert_eq!(outcome.environment.as_deref(), Some("publisher"));
}
#[test]
fn manual_trust_outcome_preserves_explicit_context_and_registry_setup_url() {
let mut request = trusted_request(RegistryKind::PubDev);
request.trusted_publishing.repository = Some("monochange/monochange".to_string());
request.trusted_publishing.workflow = Some("publish.yml".to_string());
request.trusted_publishing.environment = Some("pub.dev".to_string());
let outcome = manual_trust_outcome(&request, None, Path::new("."), &BTreeMap::new());
assert_eq!(
outcome.status,
TrustedPublishingStatus::ManualActionRequired
);
assert_eq!(outcome.repository.as_deref(), Some("monochange/monochange"));
assert_eq!(outcome.workflow.as_deref(), Some("publish.yml"));
assert_eq!(outcome.environment.as_deref(), Some("pub.dev"));
assert_eq!(
outcome.setup_url.as_deref(),
Some("https://pub.dev/packages/pkg/admin")
);
assert!(
outcome
.message
.contains("configure trusted publishing manually for `pkg`")
);
assert!(outcome.message.contains(
"register repository `monochange/monochange`, workflow `publish.yml`, environment `pub.dev`"
));
}
#[test]
fn manual_trust_outcome_includes_copyable_npm_trust_command_when_context_is_known() {
let mut request = trusted_request(RegistryKind::Npm);
request.trusted_publishing.repository = Some("monochange/monochange".to_string());
request.trusted_publishing.workflow = Some("publish.yml".to_string());
request.trusted_publishing.environment = Some("publisher".to_string());
let outcome = manual_trust_outcome(&request, None, Path::new("."), &BTreeMap::new());
assert_eq!(
outcome.status,
TrustedPublishingStatus::ManualActionRequired
);
assert!(outcome.message.contains(
"npm trust github pkg --file publish.yml --repo monochange/monochange --yes --env publisher"
));
}
#[test]
fn planned_trust_outcome_includes_copyable_npm_trust_command_when_context_is_known() {
let root = workflow_root();
let env_map = BTreeMap::from([
(
"GITHUB_REPOSITORY".to_string(),
"monochange/monochange".to_string(),
),
(
"GITHUB_WORKFLOW_REF".to_string(),
"monochange/monochange/.github/workflows/publish.yml@refs/heads/main".to_string(),
),
("GITHUB_JOB".to_string(), "release".to_string()),
]);
let outcome = planned_trust_outcome(
&trusted_request(RegistryKind::Npm),
Some(&sample_source()),
root.path(),
&env_map,
);
assert_eq!(outcome.status, TrustedPublishingStatus::Planned);
assert!(outcome.message.contains(
"would configure npm trusted publishing with `npm trust github pkg --file publish.yml --repo monochange/monochange --yes --env publisher`"
));
}
#[test]
fn manual_trust_outcome_reports_missing_github_context_configuration() {
let mut request = trusted_request(RegistryKind::Jsr);
request.trusted_publishing.repository = Some("monochange/monochange".to_string());
let outcome = manual_trust_outcome(&request, None, Path::new("."), &BTreeMap::new());
assert_eq!(
outcome.status,
TrustedPublishingStatus::ManualActionRequired
);
assert_eq!(outcome.repository.as_deref(), Some("monochange/monochange"));
assert_eq!(outcome.workflow, None);
assert!(
outcome
.message
.contains("finish the GitHub context setup first")
);
assert!(
outcome
.message
.contains("set `publish.trusted_publishing.workflow`")
);
}
#[test]
fn release_trust_prerequisites_include_provider_capability_diagnostics() {
let request = trusted_request(RegistryKind::Npm);
let error = enforce_release_trust_prerequisites(
&request,
Some(&sample_source()),
Path::new("."),
&BTreeMap::new(),
)
.expect_err("missing GitHub context should block trusted npm release publishing");
let message = error.to_string();
assert!(message.contains("local/manual publishing is not allowed"));
assert!(message.contains("No supported CI provider identity was detected"));
assert!(message.contains("supported providers: GitHub Actions, GitLab CI/CD"));
}
#[test]
fn manual_trust_outcome_reports_unsupported_ci_provider_capability() {
let request = trusted_request(RegistryKind::Npm);
let env_map = BTreeMap::from([
("CIRCLECI".to_string(), "true".to_string()),
(
"CIRCLE_PROJECT_USERNAME".to_string(),
"monochange".to_string(),
),
(
"CIRCLE_PROJECT_REPONAME".to_string(),
"monochange".to_string(),
),
("CIRCLE_WORKFLOW_ID".to_string(), "workflow".to_string()),
]);
let outcome = manual_trust_outcome(&request, None, Path::new("."), &env_map);
assert_eq!(
outcome.status,
TrustedPublishingStatus::ManualActionRequired
);
assert!(
outcome
.message
.contains("CircleCI is not supported for npm trusted publishing")
);
assert!(
outcome
.message
.contains("supported providers: GitHub Actions, GitLab CI/CD")
);
}
#[test]
fn planned_trust_outcome_returns_disabled_when_trust_is_off() {
let outcome = planned_trust_outcome(
&sample_request(RegistryKind::Npm),
None,
Path::new("."),
&BTreeMap::new(),
);
assert_eq!(outcome.status, TrustedPublishingStatus::Disabled);
}
#[test]
fn planned_and_skip_trust_outcomes_fall_back_to_manual_setup_when_context_missing() {
let request = trusted_request(RegistryKind::Npm);
let outcome = planned_trust_outcome(&request, None, Path::new("."), &BTreeMap::new());
assert_eq!(
outcome.status,
TrustedPublishingStatus::ManualActionRequired
);
let skipped = trust_outcome_for_skip(&request, None, Path::new("."), &BTreeMap::new());
assert_eq!(
skipped.status,
TrustedPublishingStatus::ManualActionRequired
);
}
#[test]
fn enforce_release_trust_prerequisites_accepts_configured_github_oidc_contexts() {
let root = workflow_root();
let env_map = BTreeMap::from([
(
"GITHUB_REPOSITORY".to_string(),
"monochange/monochange".to_string(),
),
(
"GITHUB_WORKFLOW_REF".to_string(),
"monochange/monochange/.github/workflows/publish.yml@refs/heads/main".to_string(),
),
("GITHUB_JOB".to_string(), "release".to_string()),
(
GITHUB_ACTIONS_ID_TOKEN_REQUEST_URL.to_string(),
"https://token.actions.githubusercontent.com".to_string(),
),
(
GITHUB_ACTIONS_ID_TOKEN_REQUEST_TOKEN.to_string(),
"request-token".to_string(),
),
]);
enforce_release_trust_prerequisites(
&trusted_request(RegistryKind::Npm),
Some(&sample_source()),
root.path(),
&env_map,
)
.expect("expected npm trust prereq success:");
enforce_release_trust_prerequisites(
&trusted_request(RegistryKind::CratesIo),
Some(&sample_source()),
root.path(),
&env_map,
)
.expect("expected crates.io trust prereq success:");
enforce_release_trust_prerequisites(
&sample_request(RegistryKind::Npm),
None,
root.path(),
&BTreeMap::new(),
)
.expect("expected disabled trust success:");
let mut mismatched_workflow_request = trusted_request(RegistryKind::PubDev);
mismatched_workflow_request.trusted_publishing.workflow = Some("release.yml".to_string());
let mismatched_context_error = enforce_release_trust_prerequisites(
&mismatched_workflow_request,
Some(&sample_source()),
root.path(),
&env_map,
)
.expect_err("expected mismatched context error");
assert!(
mismatched_context_error
.to_string()
.contains("expected GitHub workflow `release.yml`, but detected `publish.yml`")
);
}
#[test]
fn trusted_publishing_without_attestation_policy_does_not_request_npm_provenance() {
let mut request = trusted_request(RegistryKind::Npm);
let command = build_npm_release_publish_command(&request);
assert!(!command.args.contains(&"--provenance".to_string()));
request.attestations.require_registry_provenance = true;
let command = build_npm_release_publish_command(&request);
assert!(command.args.contains(&"--provenance".to_string()));
}
#[test]
fn enforce_release_attestation_prerequisites_accepts_supported_registry_provenance() {
let env_map = github_oidc_env();
enforce_release_attestation_prerequisites(
&trusted_provenance_request(RegistryKind::Npm),
&env_map,
)
.expect("expected npm provenance policy success");
enforce_release_attestation_prerequisites(
&trusted_provenance_request(RegistryKind::Jsr),
&env_map,
)
.expect("expected JSR provenance policy success");
}
#[test]
fn enforce_release_attestation_prerequisites_rejects_disabled_trusted_publishing() {
let mut request = sample_request(RegistryKind::Npm);
request.attestations.require_registry_provenance = true;
let error = enforce_release_attestation_prerequisites(&request, &github_oidc_env())
.expect_err("disabled trusted publishing should reject provenance policy");
let message = error.to_string();
assert!(message.contains("requires registry-native package provenance"));
assert!(message.contains("trusted publishing is disabled"));
}
#[test]
fn enforce_release_attestation_prerequisites_rejects_local_contexts() {
let error = enforce_release_attestation_prerequisites(
&trusted_provenance_request(RegistryKind::Npm),
&BTreeMap::new(),
)
.expect_err("local trusted publishing should reject provenance policy");
let message = error.to_string();
assert!(message.contains("local or unverifiable"));
assert!(message.contains("No supported CI provider identity was detected"));
}
#[test]
fn enforce_release_attestation_prerequisites_rejects_unsupported_registry_provenance() {
let error = enforce_release_attestation_prerequisites(
&trusted_provenance_request(RegistryKind::CratesIo),
&github_oidc_env(),
)
.expect_err("crates.io should reject registry provenance policy");
let message = error.to_string();
assert!(message.contains("cannot require registry-native package provenance"));
assert!(message.contains("registry-native provenance is not available"));
let error = enforce_release_attestation_prerequisites(
&trusted_provenance_request(RegistryKind::Pypi),
&github_oidc_env(),
)
.expect_err("PyPI should reject until the built-in publisher can require attestations");
let message = error.to_string();
assert!(message.contains("registry supports provenance"));
assert!(message.contains("built-in publisher"));
}
#[test]
fn enforce_release_trust_prerequisites_rejects_long_lived_npm_tokens() {
let root = workflow_root();
let env_map = BTreeMap::from([
(
"GITHUB_REPOSITORY".to_string(),
"monochange/monochange".to_string(),
),
(
"GITHUB_WORKFLOW_REF".to_string(),
"monochange/monochange/.github/workflows/publish.yml@refs/heads/main".to_string(),
),
("GITHUB_JOB".to_string(), "release".to_string()),
(
GITHUB_ACTIONS_ID_TOKEN_REQUEST_URL.to_string(),
"https://token.actions.githubusercontent.com".to_string(),
),
(
GITHUB_ACTIONS_ID_TOKEN_REQUEST_TOKEN.to_string(),
"request-token".to_string(),
),
("NPM_TOKEN".to_string(), "secret-token".to_string()),
]);
let error = enforce_release_trust_prerequisites(
&trusted_request(RegistryKind::Npm),
Some(&sample_source()),
root.path(),
&env_map,
)
.expect_err("long-lived npm tokens should be rejected");
let message = error.to_string();
assert!(message.contains("long-lived npm token environment variables"));
assert!(message.contains("NPM_TOKEN"));
}
#[test]
fn enforce_release_trust_prerequisites_rejects_unsupported_provider_registry_pairs() {
let root = workflow_root();
let circle_env = BTreeMap::from([
("CIRCLECI".to_string(), "true".to_string()),
(
"CIRCLE_PROJECT_USERNAME".to_string(),
"monochange".to_string(),
),
(
"CIRCLE_PROJECT_REPONAME".to_string(),
"monochange".to_string(),
),
("CIRCLE_WORKFLOW_ID".to_string(), "workflow".to_string()),
]);
let error = enforce_release_trust_prerequisites(
&trusted_request(RegistryKind::Npm),
Some(&sample_source()),
root.path(),
&circle_env,
)
.expect_err("CircleCI npm trusted publishing should be rejected");
let message = error.to_string();
assert!(message.contains("cannot enforce trusted publishing"));
assert!(message.contains("CircleCI"));
let gitlab_env = BTreeMap::from([
("GITLAB_CI".to_string(), "true".to_string()),
(
"CI_PROJECT_PATH".to_string(),
"monochange/monochange".to_string(),
),
("CI_JOB_ID".to_string(), "42".to_string()),
]);
enforce_release_trust_prerequisites(
&trusted_request(RegistryKind::Npm),
Some(&sample_source()),
root.path(),
&gitlab_env,
)
.expect("supported non-GitHub trusted publishing identities should pass capability checks");
}
#[test]
fn forbidden_npm_token_env_keys_detects_config_auth_tokens() {
let env_map = BTreeMap::from([
(
"npm_config_registry_auth_token".to_string(),
"secret".to_string(),
),
("NPM_CONFIG_USERCONFIG".to_string(), ".npmrc".to_string()),
]);
assert_eq!(
forbidden_npm_token_env_keys(&env_map),
vec!["npm_config_registry_auth_token".to_string()]
);
}
#[test]
fn verify_github_trust_context_reports_identity_mismatches() {
let root = workflow_root();
let request = trusted_request(RegistryKind::Npm);
let expected = GitHubTrustContext {
repository: "monochange/monochange".to_string(),
workflow: "publish.yml".to_string(),
environment: Some("publisher".to_string()),
};
let missing_repository = verify_github_trust_context(
&request,
root.path(),
&BTreeMap::new(),
&expected,
None,
Some("publish.yml"),
Some("publisher"),
)
.expect_err("missing GitHub repository should fail");
assert!(
missing_repository
.to_string()
.contains("GitHub Actions did not expose `GITHUB_REPOSITORY`")
);
let repository_mismatch = verify_github_trust_context(
&request,
root.path(),
&BTreeMap::new(),
&expected,
Some("other/repo"),
Some("publish.yml"),
Some("publisher"),
)
.expect_err("mismatched GitHub repository should fail");
assert!(
repository_mismatch.to_string().contains(
"expected GitHub repository `monochange/monochange`, but detected `other/repo`"
)
);
let missing_workflow = verify_github_trust_context(
&request,
root.path(),
&BTreeMap::new(),
&expected,
Some("monochange/monochange"),
None,
Some("publisher"),
)
.expect_err("missing GitHub workflow should fail");
assert!(
missing_workflow
.to_string()
.contains("GitHub Actions did not expose `GITHUB_WORKFLOW_REF`")
);
let environment_mismatch = verify_github_trust_context(
&request,
root.path(),
&BTreeMap::new(),
&expected,
Some("monochange/monochange"),
Some("publish.yml"),
None,
)
.expect_err("missing GitHub environment should fail");
assert!(
environment_mismatch
.to_string()
.contains("expected GitHub environment `publisher`, but detected `none`")
);
let missing_oidc = verify_github_trust_context(
&request,
root.path(),
&BTreeMap::new(),
&GitHubTrustContext {
environment: None,
..expected
},
Some("monochange/monochange"),
Some("publish.yml"),
None,
)
.expect_err("missing GitHub OIDC token request variables should fail");
assert!(missing_oidc.to_string().contains("grant `id-token: write`"));
}
#[tokio::test(flavor = "multi_thread")]
async fn execute_publish_requests_blocks_trusted_publish_before_external_command() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/pkg");
then.status(404);
});
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = sample_endpoints(&server.base_url());
let mut executor = FakeExecutor::new(Vec::new());
let error = execute_publish_requests(
Path::new("."),
Some(&sample_source()),
PackagePublishRunMode::Release,
false,
&[trusted_request(RegistryKind::Npm)],
&client,
&endpoints,
&BTreeMap::new(),
&mut executor,
)
.await
.expect_err("trusted publishing should block local release publish");
assert!(
error
.to_string()
.contains("local/manual publishing is not allowed")
);
assert!(executor.commands.is_empty());
}
#[test]
fn ensure_publish_report_succeeded_reports_failed_outcomes() {
let report = PackagePublishReport {
mode: PackagePublishRunMode::Release,
dry_run: false,
packages: vec![sample_publish_outcome(
"failed-pkg",
PackagePublishStatus::Failed,
)],
};
let error = ensure_publish_report_succeeded(&report)
.expect_err("failed publish outcome should fail command");
assert!(error.to_string().contains("failed-pkg 1.2.3"));
let report = PackagePublishReport {
mode: PackagePublishRunMode::Release,
dry_run: false,
packages: vec![sample_publish_outcome(
"done",
PackagePublishStatus::SkippedExisting,
)],
};
ensure_publish_report_succeeded(&report)
.unwrap_or_else(|error| panic!("successful publish report: {error}"));
}
#[test]
fn resume_publish_requests_skips_completed_versions_and_retries_failed_work() {
let mut completed = sample_request(RegistryKind::Npm);
completed.package_id = "done".to_string();
let mut failed = sample_request(RegistryKind::Npm);
failed.package_id = "retry".to_string();
let previous = PackagePublishReport {
mode: PackagePublishRunMode::Release,
dry_run: false,
packages: vec![
sample_publish_outcome("done", PackagePublishStatus::Published),
sample_publish_outcome("retry", PackagePublishStatus::Failed),
],
};
let (pending, resumed) = resume_publish_requests(&[completed, failed], Some(&previous))
.unwrap_or_else(|error| panic!("resume requests: {error}"));
assert_eq!(resumed.len(), 1);
assert_eq!(resumed[0].package, "done");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].package_id, "retry");
}
#[test]
fn merge_publish_resume_report_preserves_current_or_prepends_resumed_outcomes() {
let current = PackagePublishReport {
mode: PackagePublishRunMode::Release,
dry_run: false,
packages: vec![sample_publish_outcome(
"current",
PackagePublishStatus::Published,
)],
};
let unchanged = merge_publish_resume_report(
PackagePublishRunMode::Release,
false,
Vec::new(),
current.clone(),
);
assert_eq!(unchanged, current);
let merged = merge_publish_resume_report(
PackagePublishRunMode::Release,
false,
vec![sample_publish_outcome(
"resumed",
PackagePublishStatus::SkippedExisting,
)],
current,
);
assert_eq!(merged.packages.len(), 2);
assert_eq!(merged.packages[0].package, "resumed");
assert_eq!(merged.packages[1].package, "current");
}
#[test]
fn resume_publish_requests_rejects_dry_run_and_placeholder_reports() {
let report = PackagePublishReport {
mode: PackagePublishRunMode::Release,
dry_run: true,
packages: Vec::new(),
};
let error =
resume_publish_requests(&[], Some(&report)).expect_err("dry-run resume report should fail");
assert!(error.to_string().contains("real publish run"));
let report = PackagePublishReport {
mode: PackagePublishRunMode::Placeholder,
dry_run: false,
packages: Vec::new(),
};
let error = resume_publish_requests(&[], Some(&report))
.expect_err("placeholder resume report should fail");
assert!(error.to_string().contains("mc step:publish-packages"));
}
#[test]
fn publish_report_artifact_round_trips_and_reports_io_errors() {
let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let report = PackagePublishReport {
mode: PackagePublishRunMode::Release,
dry_run: false,
packages: vec![sample_publish_outcome(
"done",
PackagePublishStatus::SkippedExisting,
)],
};
let output = tempdir.path().join("nested/publish-result.json");
write_publish_report_artifact(&output, &report)
.unwrap_or_else(|error| panic!("write report: {error}"));
let read_report = read_publish_report_artifact(&output)
.unwrap_or_else(|error| panic!("read report: {error}"));
assert_eq!(read_report, report);
let missing_error = read_publish_report_artifact(&tempdir.path().join("missing.json"))
.expect_err("missing artifact should fail");
assert!(missing_error.to_string().contains("failed to read"));
let invalid_json_path = tempdir.path().join("invalid.json");
fs::write(&invalid_json_path, "not json")
.unwrap_or_else(|error| panic!("write invalid json: {error}"));
let parse_error =
read_publish_report_artifact(&invalid_json_path).expect_err("invalid artifact should fail");
assert!(parse_error.to_string().contains("failed to parse"));
let write_error = write_publish_report_artifact(tempdir.path(), &report)
.expect_err("directory output path should fail");
assert!(write_error.to_string().contains("failed to write"));
let parent_file = tempdir.path().join("file-parent");
fs::write(&parent_file, "not a directory")
.unwrap_or_else(|error| panic!("write file parent: {error}"));
let create_error = write_publish_report_artifact(&parent_file.join("result.json"), &report)
.expect_err("file parent should fail directory creation");
assert!(create_error.to_string().contains("failed to create"));
assert!(
publish_report_json_error("bad json")
.to_string()
.contains("failed to serialize package publish report")
);
}
fn write_cargo_manifest(root: &Path, contents: &str) -> PathBuf {
let package_root = root.join("pkg");
fs::create_dir_all(&package_root).expect("package dir");
let manifest_path = package_root.join("Cargo.toml");
fs::write(&manifest_path, contents).expect("write Cargo manifest");
manifest_path
}
fn sample_cargo_request(root: &Path, manifest_path: &Path) -> PublishRequest {
PublishRequest {
manifest_path: manifest_path.to_path_buf(),
package_root: root.join("pkg"),
..sample_request(RegistryKind::CratesIo)
}
}
#[test]
fn cargo_publish_readiness_blockers_require_crates_io_metadata_and_publish_access() {
let root = tempfile::tempdir().expect("tempdir");
let manifest_path = write_cargo_manifest(
root.path(),
r#"
[package]
name = "pkg"
version = "1.2.3"
publish = ["internal"]
"#,
);
let request = sample_cargo_request(root.path(), &manifest_path);
let blockers = cargo_publish_readiness_blockers(root.path(), &request).expect("blockers");
assert!(blockers.contains(&"package.publish does not include crates-io".to_string()));
assert!(blockers.contains(&"package.description is required for crates.io".to_string()));
assert!(blockers.contains(
&"package.license or package.license-file is required for crates.io".to_string()
));
}
#[test]
fn cargo_publish_readiness_blockers_ignore_non_cargo_requests() {
let blockers =
cargo_publish_readiness_blockers(Path::new("."), &sample_request(RegistryKind::Npm))
.expect("blockers");
assert!(blockers.is_empty());
}
#[test]
fn cargo_publish_readiness_blockers_report_manifest_errors() {
let root = tempfile::tempdir().expect("tempdir");
let missing = root.path().join("pkg/Cargo.toml");
let missing_request = sample_cargo_request(root.path(), &missing);
let missing_error = cargo_publish_readiness_blockers(root.path(), &missing_request)
.expect_err("expected read error");
assert!(
missing_error
.to_string()
.contains("failed to read Cargo manifest")
);
let invalid = write_cargo_manifest(root.path(), "not valid toml");
let invalid_request = sample_cargo_request(root.path(), &invalid);
let invalid_error = cargo_publish_readiness_blockers(root.path(), &invalid_request)
.expect_err("expected parse error");
assert!(invalid_error.to_string().contains("failed to parse"));
}
#[test]
fn cargo_publish_readiness_blockers_report_missing_package_table() {
let root = tempfile::tempdir().expect("tempdir");
let manifest_path = write_cargo_manifest(root.path(), "[workspace]\nmembers = []\n");
let request = sample_cargo_request(root.path(), &manifest_path);
let blockers = cargo_publish_readiness_blockers(root.path(), &request).expect("blockers");
assert_eq!(
blockers,
vec!["Cargo manifest is missing [package]".to_string()]
);
}
#[test]
fn cargo_publish_readiness_blockers_reject_publish_false() {
let root = tempfile::tempdir().expect("tempdir");
let manifest_path = write_cargo_manifest(
root.path(),
r#"
[package]
name = "pkg"
version = "1.2.3"
description = "A package"
license = "MIT"
publish = false
"#,
);
let request = sample_cargo_request(root.path(), &manifest_path);
let blockers = cargo_publish_readiness_blockers(root.path(), &request).expect("blockers");
assert_eq!(blockers, vec!["package.publish is false".to_string()]);
}
#[test]
fn cargo_publish_readiness_blockers_accept_workspace_inherited_metadata() {
let root = tempfile::tempdir().expect("tempdir");
fs::write(
root.path().join("Cargo.toml"),
r#"
[workspace.package]
description = "Workspace description"
license = "MIT"
"#,
)
.expect("write workspace manifest");
let manifest_path = write_cargo_manifest(
root.path(),
r#"
[package]
name = "pkg"
version = "1.2.3"
description = { workspace = true }
license = { workspace = true }
publish = ["crates-io"]
"#,
);
let request = sample_cargo_request(root.path(), &manifest_path);
let blockers = cargo_publish_readiness_blockers(root.path(), &request).expect("blockers");
assert!(blockers.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn execute_publish_requests_marks_dry_run_cargo_metadata_blockers() {
let root = tempfile::tempdir().expect("tempdir");
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/crates/pkg");
then.status(404);
});
let manifest_path = write_cargo_manifest(
root.path(),
r#"
[package]
name = "pkg"
version = "1.2.3"
"#,
);
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = sample_endpoints(&server.base_url());
let request = sample_cargo_request(root.path(), &manifest_path);
let mut executor = FakeExecutor::new(Vec::new());
let report = execute_publish_requests(
root.path(),
Some(&sample_source()),
PackagePublishRunMode::Release,
true,
&[request],
&client,
&endpoints,
&BTreeMap::new(),
&mut executor,
)
.await
.expect("report");
assert_eq!(report.packages[0].status, PackagePublishStatus::Blocked);
assert!(
report.packages[0]
.message
.contains("package.description is required for crates.io")
);
assert!(executor.commands.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn execute_publish_requests_rejects_real_cargo_metadata_blockers() {
let root = tempfile::tempdir().expect("tempdir");
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/crates/pkg");
then.status(404);
});
let manifest_path = write_cargo_manifest(
root.path(),
r#"
[package]
name = "pkg"
version = "1.2.3"
"#,
);
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = sample_endpoints(&server.base_url());
let request = sample_cargo_request(root.path(), &manifest_path);
let mut executor = FakeExecutor::new(Vec::new());
let error = execute_publish_requests(
root.path(),
Some(&sample_source()),
PackagePublishRunMode::Release,
false,
&[request],
&client,
&endpoints,
&BTreeMap::new(),
&mut executor,
)
.await
.expect_err("expected readiness blocker");
assert!(
error
.to_string()
.contains("pkg 1.2.3 is not ready to publish to crates_io")
);
assert!(executor.commands.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn execute_publish_requests_skips_external_and_existing_versions() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/pkg");
then.status(200).json_body_obj(&serde_json::json!({
"versions": { "1.2.3": {} }
}));
});
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = RegistryEndpoints {
npm_registry: server.base_url(),
crates_io_api: server.base_url(),
crates_io_index: server.base_url(),
pub_dev_api: server.base_url(),
jsr_base: server.base_url(),
pypi_api: server.base_url(),
go_proxy: server.base_url(),
};
let request = PublishRequest {
mode: PublishMode::External,
..sample_request(RegistryKind::Npm)
};
let existing = sample_request(RegistryKind::Npm);
let mut executor = FakeExecutor::new(Vec::new());
let report = execute_publish_requests(
Path::new("."),
Some(&sample_source()),
PackagePublishRunMode::Release,
true,
&[request, existing],
&client,
&endpoints,
&BTreeMap::new(),
&mut executor,
)
.await
.expect("report:");
assert_eq!(report.packages.len(), 2);
assert_eq!(
report.packages[0].status,
PackagePublishStatus::SkippedExternal
);
assert_eq!(
report.packages[1].status,
PackagePublishStatus::SkippedExisting
);
}
#[tokio::test(flavor = "multi_thread")]
async fn filter_pending_publish_requests_skips_external_and_existing_versions() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/pkg");
then.status(200).json_body_obj(&serde_json::json!({
"versions": { "1.2.3": {} }
}));
});
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = sample_endpoints(&server.base_url());
let request = PublishRequest {
mode: PublishMode::External,
..sample_request(RegistryKind::Npm)
};
let existing = sample_request(RegistryKind::Npm);
let pending = PublishRequest {
package_id: "pkg-next".to_string(),
package_name: "pkg-next".to_string(),
..sample_request(RegistryKind::Npm)
};
server.mock(|when, then| {
when.method(GET).path("/pkg-next");
then.status(404);
});
let filtered = filter_pending_publish_requests_with_transport(
&[request, existing, pending],
&client,
&endpoints,
)
.await
.expect("filtered requests:");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].package_id, "pkg-next");
}
#[tokio::test(flavor = "multi_thread")]
async fn execute_publish_requests_publishes_release_with_trust_outcome() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/pkg");
then.status(404);
});
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = sample_endpoints(&server.base_url());
let root = workflow_root();
let env_map = BTreeMap::from([
(
"GITHUB_REPOSITORY".to_string(),
"monochange/monochange".to_string(),
),
(
"GITHUB_WORKFLOW_REF".to_string(),
"monochange/monochange/.github/workflows/publish.yml@refs/heads/main".to_string(),
),
("GITHUB_JOB".to_string(), "release".to_string()),
(
GITHUB_ACTIONS_ID_TOKEN_REQUEST_URL.to_string(),
"https://token.actions.githubusercontent.com".to_string(),
),
(
GITHUB_ACTIONS_ID_TOKEN_REQUEST_TOKEN.to_string(),
"request-token".to_string(),
),
]);
let mut executor = FakeExecutor::new(vec![CommandOutput {
success: true,
stdout: String::new(),
stderr: String::new(),
}]);
let report = execute_publish_requests(
root.path(),
Some(&sample_source()),
PackagePublishRunMode::Release,
false,
&[trusted_request(RegistryKind::Npm)],
&client,
&endpoints,
&env_map,
&mut executor,
)
.await
.expect("report:");
assert_eq!(report.packages.len(), 1);
assert_eq!(report.packages[0].status, PackagePublishStatus::Published);
assert_eq!(
report.packages[0].trusted_publishing.status,
TrustedPublishingStatus::Configured
);
assert!(
report.packages[0]
.trusted_publishing
.message
.contains("npm trusted publishing")
);
assert_eq!(executor.commands.len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn release_dry_run_orders_cargo_dev_and_build_dependencies_before_dependents() {
let expected_order = vec![
"zephyr-build",
"lima-dev",
"quartz-build",
"ember-dev",
"nyx-build",
"cedar-dev",
"violet-build",
"osprey-build",
"delta-dev",
"amber-dev",
"mango-build",
"sable-dev",
"orbit-build",
"binary-build",
"atlas-dev",
"config",
];
let packages = [
("config", Some(("build-dependencies", "atlas-dev"))),
("atlas-dev", Some(("build-dependencies", "binary-build"))),
("binary-build", Some(("dev-dependencies", "orbit-build"))),
("orbit-build", Some(("build-dependencies", "sable-dev"))),
("sable-dev", Some(("dev-dependencies", "mango-build"))),
("mango-build", Some(("build-dependencies", "amber-dev"))),
("amber-dev", Some(("dev-dependencies", "delta-dev"))),
("delta-dev", Some(("build-dependencies", "osprey-build"))),
("osprey-build", Some(("dev-dependencies", "violet-build"))),
("violet-build", Some(("build-dependencies", "cedar-dev"))),
("cedar-dev", Some(("dev-dependencies", "nyx-build"))),
("nyx-build", Some(("build-dependencies", "ember-dev"))),
("ember-dev", Some(("dev-dependencies", "quartz-build"))),
("quartz-build", Some(("build-dependencies", "lima-dev"))),
("lima-dev", Some(("dev-dependencies", "zephyr-build"))),
("zephyr-build", None),
];
let server = MockServer::start();
for (package, _) in packages {
server.mock(|when, then| {
when.method(GET).path(format!("/crates/{package}"));
then.status(404);
});
}
let root = tempfile::tempdir().expect("tempdir:");
let members = packages
.iter()
.map(|(package, _)| format!("\"{package}\""))
.collect::<Vec<_>>()
.join(", ");
fs::write(
root.path().join("Cargo.toml"),
format!("[workspace]\nmembers = [{members}]\nresolver = \"2\"\n"),
)
.expect("write workspace manifest:");
for (package, dependency) in packages {
let package_dir = root.path().join(package);
fs::create_dir_all(package_dir.join("src")).expect("mkdir package:");
fs::write(package_dir.join("src/lib.rs"), "").expect("write package lib:");
let dependency_section = dependency.map_or_else(String::new, |(kind, dependency)| {
format!(
"\n[{kind}]\n{dependency} = {{ path = \"../{dependency}\", version = \"1.0.0\" }}\n"
)
});
fs::write(
package_dir.join("Cargo.toml"),
format!(
concat!(
"[package]\n",
"name = \"{package}\"\n",
"version = \"1.0.0\"\n",
"edition = \"2021\"\n",
"license = \"MIT\"\n",
"description = \"test package\"\n",
"{dependency_section}"
),
package = package,
dependency_section = dependency_section,
),
)
.expect("write package manifest:");
}
let configuration_packages = packages
.iter()
.map(|(package, _)| (*package, monochange_core::PackageType::Cargo, true))
.collect::<Vec<_>>();
let configuration = sample_configuration(&configuration_packages);
let publications = packages
.into_iter()
.map(|(package, _)| {
PackagePublicationTarget {
package: package.to_string(),
ecosystem: Ecosystem::Cargo,
registry: Some(PublishRegistry::Builtin(RegistryKind::CratesIo)),
version: "1.0.0".to_string(),
mode: PublishMode::Builtin,
trusted_publishing: TrustedPublishingSettings::default(),
attestations: PublishAttestationSettings::default(),
}
})
.collect::<Vec<_>>();
temp_env::async_with_vars(
[("MONOCHANGE_CRATES_IO_API_URL", Some(server.base_url()))],
async {
let report = run_publish_packages_with_publications(
root.path(),
&configuration,
&publications,
&BTreeSet::new(),
true,
)
.await
.expect("publish report:");
let order = report
.packages
.iter()
.map(|package| package.package.as_str())
.collect::<Vec<_>>();
assert_eq!(order, expected_order);
},
)
.await;
}
#[tokio::test(flavor = "multi_thread")]
async fn execute_publish_requests_placeholder_dry_run_validates_publish_command() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/pkg");
then.status(404);
});
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = sample_endpoints(&server.base_url());
let mut executor = FakeExecutor::new(vec![CommandOutput {
success: true,
stdout: String::new(),
stderr: String::new(),
}]);
let report = execute_publish_requests(
Path::new("."),
None,
PackagePublishRunMode::Release,
true,
&[sample_request(RegistryKind::Npm)],
&client,
&endpoints,
&BTreeMap::new(),
&mut executor,
)
.await
.expect("report:");
assert_eq!(report.packages[0].status, PackagePublishStatus::Planned);
assert_eq!(executor.commands.len(), 1);
assert!(
executor.commands[0]
.args
.iter()
.any(|arg| arg == "--dry-run")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn execute_publish_requests_placeholder_dry_run_surfaces_manifest_prerequisites() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/crates/pkg");
then.status(404);
});
let root = tempfile::tempdir().expect("tempdir:");
let package_root = root.path().join("pkg");
fs::create_dir_all(&package_root).expect("mkdir:");
fs::write(
package_root.join("Cargo.toml"),
concat!("[package]\n", "name = \"pkg\"\n", "version = \"1.0.0\"\n",),
)
.expect("write manifest:");
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = sample_endpoints(&server.base_url());
let mut request = sample_request(RegistryKind::CratesIo);
request.manifest_path = package_root.join("Cargo.toml");
request.package_root = package_root;
request.placeholder = true;
let mut executor = FakeExecutor::new(Vec::new());
let error = execute_publish_requests(
root.path(),
Some(&sample_source()),
PackagePublishRunMode::Placeholder,
true,
&[request],
&client,
&endpoints,
&BTreeMap::new(),
&mut executor,
)
.await
.expect_err("expected placeholder manifest error");
assert!(
error.to_string().contains(
"placeholder publishing requires `package.license` or `package.license-file`"
)
);
assert!(executor.commands.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn execute_publish_requests_publishes_placeholder_and_flags_manual_trust() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/crates/pkg");
then.status(404);
});
let root = tempfile::tempdir().expect("tempdir:");
let package_root = root.path().join("pkg");
fs::create_dir_all(&package_root).expect("mkdir:");
fs::write(
package_root.join("Cargo.toml"),
concat!(
"[package]\n",
"name = \"pkg\"\n",
"version = \"1.0.0\"\n",
"license = \"MIT\"\n",
),
)
.expect("write manifest:");
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = sample_endpoints(&server.base_url());
let mut request = trusted_request(RegistryKind::CratesIo);
request.manifest_path = package_root.join("Cargo.toml");
request.package_root = package_root;
let mut executor = FakeExecutor::new(vec![CommandOutput {
success: true,
stdout: String::new(),
stderr: String::new(),
}]);
let report = execute_publish_requests(
root.path(),
Some(&sample_source()),
PackagePublishRunMode::Placeholder,
false,
&[request],
&client,
&endpoints,
&BTreeMap::new(),
&mut executor,
)
.await
.expect("report:");
assert_eq!(report.packages[0].status, PackagePublishStatus::Published);
assert_eq!(
report.packages[0].trusted_publishing.status,
TrustedPublishingStatus::ManualActionRequired
);
assert_eq!(executor.commands[0].program, "cargo");
}
#[tokio::test(flavor = "multi_thread")]
async fn execute_publish_requests_surfaces_publish_command_failures() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/pkg");
then.status(404);
});
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client:");
let endpoints = sample_endpoints(&server.base_url());
let mut executor = FakeExecutor::new(vec![CommandOutput {
success: false,
stdout: String::new(),
stderr: "boom".to_string(),
}]);
let report = execute_publish_requests(
Path::new("."),
None,
PackagePublishRunMode::Release,
false,
&[sample_request(RegistryKind::Npm)],
&client,
&endpoints,
&BTreeMap::new(),
&mut executor,
)
.await
.expect("publish report");
assert_eq!(report.packages[0].status, PackagePublishStatus::Failed);
assert!(report.packages[0].message.contains("npm publish"));
assert!(report.packages[0].message.contains("boom"));
let mut executor = FakeExecutor::new(Vec::new());
let report = execute_publish_requests(
Path::new("."),
None,
PackagePublishRunMode::Release,
false,
&[sample_request(RegistryKind::Npm)],
&client,
&endpoints,
&BTreeMap::new(),
&mut executor,
)
.await
.expect("publish report");
assert_eq!(report.packages[0].status, PackagePublishStatus::Failed);
assert!(
report.packages[0]
.message
.contains("missing fake command output")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn execute_publish_requests_uses_disabled_trust_outcome_for_successful_builtin_publish() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/pkg");
then.status(404);
});
install_rustls_ring_provider();
let client = Client::builder().build().expect("http client");
let endpoints = sample_endpoints(&server.base_url());
let mut executor = FakeExecutor::new(vec![CommandOutput {
success: true,
stdout: String::new(),
stderr: String::new(),
}]);
let report = execute_publish_requests(
Path::new("."),
None,
PackagePublishRunMode::Release,
false,
&[sample_request(RegistryKind::Npm)],
&client,
&endpoints,
&BTreeMap::new(),
&mut executor,
)
.await
.expect("report");
assert_eq!(
report.packages[0].trusted_publishing.status,
TrustedPublishingStatus::Disabled
);
}
#[tokio::test(flavor = "multi_thread")]
async fn run_placeholder_publish_uses_env_overrides_for_registry_endpoints() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/pkg");
then.status(404);
});
let root = tempfile::tempdir().expect("tempdir:");
fs::write(
root.path().join("monochange.toml"),
"[package.pkg]\npath = \"packages/pkg\"\ntype = \"npm\"\n",
)
.expect("config:");
fs::create_dir_all(root.path().join("packages/pkg")).expect("mkdir:");
fs::write(
root.path().join("packages/pkg/package.json"),
r#"{ "name": "pkg", "version": "1.0.0" }"#,
)
.expect("manifest:");
let configuration = crate::load_workspace_configuration(root.path()).expect("configuration:");
temp_env::async_with_vars(
vec![(
"MONOCHANGE_NPM_REGISTRY_URL",
Some(server.base_url().as_str()),
)],
async {
let report =
run_placeholder_publish(root.path(), &configuration, &BTreeSet::new(), true)
.await
.expect("placeholder report:");
assert_eq!(report.mode, PackagePublishRunMode::Placeholder);
assert_eq!(report.packages.len(), 1);
assert_eq!(report.packages[0].status, PackagePublishStatus::Planned);
},
)
.await;
}
#[tokio::test(flavor = "multi_thread")]
async fn run_publish_packages_uses_prepared_release_publications() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/pkg");
then.status(404);
});
let root = tempfile::tempdir().expect("tempdir:");
fs::write(
root.path().join("monochange.toml"),
"[package.pkg]\npath = \"packages/pkg\"\ntype = \"npm\"\n",
)
.expect("config:");
fs::create_dir_all(root.path().join("packages/pkg")).expect("mkdir:");
fs::write(
root.path().join("packages/pkg/package.json"),
r#"{ "name": "pkg", "version": "1.0.0" }"#,
)
.expect("manifest:");
let configuration = crate::load_workspace_configuration(root.path()).expect("configuration:");
let prepared_release = sample_prepared_release(
root.path(),
vec![PackagePublicationTarget {
package: "pkg".to_string(),
ecosystem: Ecosystem::Npm,
registry: Some(PublishRegistry::Builtin(RegistryKind::Npm)),
version: "1.2.3".to_string(),
mode: PublishMode::Builtin,
trusted_publishing: TrustedPublishingSettings::default(),
attestations: PublishAttestationSettings::default(),
}],
);
with_locked_env_vars(|| {
with_vars(
vec![(
"MONOCHANGE_NPM_REGISTRY_URL",
Some(server.base_url().as_str()),
)],
|| {
let report = crate::cli_runtime::block_on_in_context(run_publish_packages(
root.path(),
&configuration,
Some(&prepared_release),
&BTreeSet::new(),
true,
))
.expect("publish report:");
assert_eq!(report.mode, PackagePublishRunMode::Release);
assert_eq!(report.packages.len(), 1);
assert_eq!(report.packages[0].status, PackagePublishStatus::Planned);
assert_eq!(report.packages[0].version, "1.2.3");
},
);
});
}
#[tokio::test(flavor = "multi_thread")]
async fn run_publish_packages_discovers_release_record_publications_from_head() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/pkg");
then.status(404);
});
let root = tempfile::tempdir().expect("tempdir:");
fs::write(
root.path().join("monochange.toml"),
"[package.pkg]\npath = \"packages/pkg\"\ntype = \"npm\"\n",
)
.expect("config:");
fs::create_dir_all(root.path().join("packages/pkg")).expect("mkdir:");
fs::write(
root.path().join("packages/pkg/package.json"),
r#"{ "name": "pkg", "version": "1.0.0" }"#,
)
.expect("manifest:");
fs::write(root.path().join("tracked.txt"), "initial\n").expect("tracked:");
git(root.path(), &["init"]);
git(root.path(), &["config", "user.name", "monochange Tests"]);
git(
root.path(),
&["config", "user.email", "monochange@example.com"],
);
git(root.path(), &["add", "."]);
git(root.path(), &["commit", "-m", "initial"]);
let configuration = crate::load_workspace_configuration(root.path()).expect("configuration:");
commit_release_record(
root.path(),
vec![PackagePublicationTarget {
package: "pkg".to_string(),
ecosystem: Ecosystem::Npm,
registry: Some(PublishRegistry::Builtin(RegistryKind::Npm)),
version: "1.2.3".to_string(),
mode: PublishMode::Builtin,
trusted_publishing: TrustedPublishingSettings::default(),
attestations: PublishAttestationSettings::default(),
}],
);
let discovered = release_record_package_publications_from_prepared_or_head(root.path(), None)
.await
.expect("release record publications");
assert_eq!(discovered.len(), 1);
with_locked_env_vars(|| {
with_vars(
vec![(
"MONOCHANGE_NPM_REGISTRY_URL",
Some(server.base_url().as_str()),
)],
|| {
let report = crate::cli_runtime::block_on_in_context(run_publish_packages(
root.path(),
&configuration,
None,
&BTreeSet::new(),
true,
))
.expect("publish report:");
assert_eq!(report.mode, PackagePublishRunMode::Release);
assert_eq!(report.packages.len(), 1);
assert_eq!(report.packages[0].status, PackagePublishStatus::Planned);
assert_eq!(report.packages[0].version, "1.2.3");
},
);
});
}
#[test]
fn process_command_executor_runs_commands_and_reports_spawn_failures() {
let tempdir = tempfile::tempdir().expect("tempdir:");
let mut executor = ProcessCommandExecutor;
let success = executor
.run(&CommandSpec {
program: "sh".to_string(),
args: vec![
"-c".to_string(),
"printf stdout; printf stderr >&2".to_string(),
],
cwd: tempdir.path().to_path_buf(),
})
.expect("expected command success:");
assert!(success.success);
assert_eq!(success.stdout, "stdout");
assert_eq!(success.stderr, "stderr");
let error = executor
.run(&CommandSpec {
program: "definitely-not-a-real-command".to_string(),
args: Vec::new(),
cwd: tempdir.path().to_path_buf(),
})
.expect_err("expected command failure");
assert!(
error
.to_string()
.contains("failed to run `definitely-not-a-real-command`")
);
}
#[test]
fn fake_executor_reports_missing_outputs_and_render_helpers_match() {
let mut executor = FakeExecutor::new(Vec::new());
let spec = CommandSpec {
program: "npm".to_string(),
args: vec![
"publish".to_string(),
"--access".to_string(),
"public".to_string(),
],
cwd: PathBuf::from("."),
};
let error = executor
.run(&spec)
.expect_err("expected fake executor error");
assert!(error.to_string().contains("missing fake command output"));
assert_eq!(render_command(&spec), "npm publish --access public");
assert_eq!(
render_command_error(&CommandOutput {
success: false,
stdout: String::new(),
stderr: String::new(),
}),
"command failed"
);
}
#[test]
fn append_publish_dry_run_args_replaces_force_with_dry_run_for_pubdev() {
let mut args = vec![
"pub".to_string(),
"publish".to_string(),
"--force".to_string(),
];
append_publish_dry_run_args(&mut args, RegistryKind::PubDev, true);
assert!(!args.contains(&"--force".to_string()));
assert!(args.contains(&"--dry-run".to_string()));
}
#[test]
fn append_publish_dry_run_args_appends_standard_flag_for_non_pubdev_registries() {
for registry in [RegistryKind::Npm, RegistryKind::CratesIo, RegistryKind::Jsr] {
let mut args = vec!["publish".to_string()];
append_publish_dry_run_args(&mut args, registry, true);
assert_eq!(args.last(), Some(&"--dry-run".to_string()));
}
}
#[test]
fn build_npm_placeholder_publish_command_uses_package_root_as_cwd() {
let command = build_npm_placeholder_publish_command(
&sample_request(RegistryKind::Npm),
Path::new("/tmp/placeholder"),
);
assert_eq!(command.program, "npm");
assert_eq!(command.cwd, PathBuf::from("/workspace/pkg"));
assert_eq!(command.args[0], "publish");
}
#[test]
fn write_cargo_placeholder_manifest_reads_workspace_license_file_from_root() {
let root = tempfile::tempdir().expect("tempdir");
let package_root = root.path().join("pkg");
fs::create_dir_all(&package_root).expect("mkdir");
fs::write(
root.path().join("Cargo.toml"),
concat!(
"[workspace]\n",
"members = [\"pkg\"]\n",
"[workspace.package]\n",
"license-file = \"LICENSE\"\n",
),
)
.expect("write workspace manifest");
fs::write(
package_root.join("Cargo.toml"),
concat!("[package]\n", "name = \"pkg\"\n", "version = \"1.0.0\"\n"),
)
.expect("write package manifest");
fs::write(root.path().join("LICENSE"), "MIT").expect("write license");
let request = PublishRequest {
manifest_path: package_root.join("Cargo.toml"),
package_root,
..sample_request(RegistryKind::CratesIo)
};
let placeholder_dir = tempfile::tempdir().expect("tempdir");
write_cargo_placeholder_manifest(placeholder_dir.path(), &request, root.path(), None)
.expect("cargo placeholder");
let placeholder_manifest = fs::read_to_string(placeholder_dir.path().join("Cargo.toml"))
.expect("read placeholder manifest");
assert!(placeholder_manifest.contains("license-file = \"LICENSE\""));
assert_eq!(
fs::read_to_string(placeholder_dir.path().join("LICENSE"))
.expect("read placeholder license"),
"MIT"
);
}
#[test]
fn extract_workspace_package_table_returns_workspace_package_table() {
let parsed = toml::from_str::<TomlValue>(concat!(
"[workspace]\n",
"members = [\"pkg\"]\n",
"[workspace.package]\n",
"license = \"MIT\"\n",
))
.expect("parse manifest");
let workspace_package = extract_workspace_package_table(&parsed).expect("package table");
assert_eq!(
workspace_package.get("license").and_then(TomlValue::as_str),
Some("MIT")
);
}
#[test]
fn read_workspace_package_table_returns_workspace_package_table() {
let root = tempfile::tempdir().expect("tempdir");
fs::write(
root.path().join("Cargo.toml"),
concat!(
"[workspace]\n",
"members = [\"pkg\"]\n",
"[workspace.package]\n",
"license = \"MIT\"\n",
),
)
.expect("write manifest");
let workspace_package = read_workspace_package_table(root.path())
.expect("workspace package")
.expect("package table");
assert_eq!(
workspace_package.get("license").and_then(TomlValue::as_str),
Some("MIT")
);
}
#[test]
fn read_workspace_package_table_reports_io_and_parse_errors() {
let root = tempfile::tempdir().expect("tempdir");
let read_result = read_workspace_package_table(root.path());
assert!(read_result.is_ok());
assert!(read_result.expect("read").is_none());
let manifest_path = root.path().join("Cargo.toml");
fs::write(&manifest_path, "[workspace]\nmembers = []\n").expect("write manifest");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(&manifest_path)
.expect("metadata")
.permissions();
permissions.set_mode(0o000);
fs::set_permissions(&manifest_path, permissions).expect("chmod");
let read_error =
read_workspace_package_table(root.path()).expect_err("expected read error");
assert!(
read_error
.to_string()
.contains("failed to read Cargo manifest")
);
let mut restore = fs::metadata(&manifest_path)
.expect("metadata")
.permissions();
restore.set_mode(0o644);
fs::set_permissions(&manifest_path, restore).expect("restore chmod");
}
fs::write(&manifest_path, "not valid toml").expect("write invalid");
let parse_result = read_workspace_package_table(root.path());
let error = parse_result.expect_err("expected parse error");
assert!(error.to_string().contains("failed to parse"));
}
#[test]
fn build_release_requests_uses_publication_targets_and_package_metadata() {
let package = PackageRecord {
id: "npm:packages/pkg/package.json".to_string(),
name: "pkg".to_string(),
ecosystem: Ecosystem::Npm,
manifest_path: PathBuf::from("/workspace/packages/pkg/package.json"),
workspace_root: PathBuf::from("/workspace"),
current_version: Some(Version::parse("1.0.0").expect("version:")),
publish_state: PublishState::Public,
version_group_id: None,
metadata: BTreeMap::from([
("config_id".to_string(), "pkg".to_string()),
("manager".to_string(), "pnpm".to_string()),
]),
declared_dependencies: Vec::new(),
};
let publication = PackagePublicationTarget {
package: "pkg".to_string(),
ecosystem: Ecosystem::Npm,
registry: Some(PublishRegistry::Builtin(RegistryKind::Npm)),
version: "1.2.3".to_string(),
mode: PublishMode::Builtin,
trusted_publishing: TrustedPublishingSettings::default(),
attestations: PublishAttestationSettings::default(),
};
let configuration = sample_configuration(&[("pkg", monochange_core::PackageType::Npm, true)]);
let requests =
build_release_requests(&configuration, &[package], &[publication], &BTreeSet::new())
.expect("requests:");
assert_eq!(requests.len(), 1);
let request = requests.first().expect("request");
assert_eq!(request.version, "1.2.3");
assert_eq!(request.package_name, "pkg");
assert_eq!(request.package_manager.as_deref(), Some("pnpm"));
assert_eq!(
request.package_metadata.get("manager").map(String::as_str),
Some("pnpm")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn run_publish_packages_with_resume_filters_by_group_and_ecosystem() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/pkg");
then.status(404);
});
let root = tempfile::tempdir().expect("tempdir:");
fs::write(
root.path().join("monochange.toml"),
"[package.pkg]\npath = \"packages/pkg\"\ntype = \"npm\"\n\n[group.my-group]\npackages = [\"pkg\"]\n",
)
.expect("config:");
fs::create_dir_all(root.path().join("packages/pkg")).expect("mkdir:");
fs::write(
root.path().join("packages/pkg/package.json"),
r#"{ "name": "pkg", "version": "1.0.0" }"#,
)
.expect("manifest:");
let configuration = crate::load_workspace_configuration(root.path()).expect("configuration:");
let prepared_release = sample_prepared_release(
root.path(),
vec![PackagePublicationTarget {
package: "pkg".to_string(),
ecosystem: Ecosystem::Npm,
registry: Some(PublishRegistry::Builtin(RegistryKind::Npm)),
version: "1.2.3".to_string(),
mode: PublishMode::Builtin,
trusted_publishing: TrustedPublishingSettings::default(),
attestations: PublishAttestationSettings::default(),
}],
);
let mut selected_groups = BTreeSet::new();
selected_groups.insert("my-group".to_string());
let mut selected_ecosystems = BTreeSet::new();
selected_ecosystems.insert(Ecosystem::Npm);
with_locked_env_vars(|| {
with_vars(
vec![(
"MONOCHANGE_NPM_REGISTRY_URL",
Some(server.base_url().as_str()),
)],
|| {
let report =
crate::cli_runtime::block_on_in_context(run_publish_packages_with_resume(
root.path(),
&configuration,
Some(&prepared_release),
&BTreeSet::new(),
&selected_groups,
&selected_ecosystems,
false,
true,
None,
))
.expect("publish report:");
assert_eq!(report.packages.len(), 1);
assert_eq!(report.packages[0].package, "pkg");
},
);
});
}