use std::collections::{BTreeMap, BTreeSet}; use anyhow::bail; use cargo_metadata::{Dependency, DependencyKind, Metadata, Package}; use crates_index::Index; use crate::{ changelog, changelog::{write::Linkables, Section}, command::release::Options, utils::{ is_dependency_with_version_requirement, names_and_versions, package_by_id, package_by_name, package_eq_dependency, package_for_dependency, tag_name, will, workspace_package_by_id, }, }; mod cargo; mod git; mod github; mod manifest; mod version; type Oid<'repo> = git_repository::easy::Oid<'repo, git_repository::Easy>; pub(crate) struct Context { base: crate::Context, crates_index: Index, history: Option, bump: BumpSpec, bump_dependencies: BumpSpec, changelog_links: Linkables, } impl Context { fn new( crate_names: Vec, bump: BumpSpec, bump_dependencies: BumpSpec, changelog: bool, changelog_links: bool, ) -> anyhow::Result { let crates_index = Index::new_cargo_default(); let base = crate::Context::new(crate_names)?; let history = (changelog || matches!(bump, BumpSpec::Auto) || matches!(bump_dependencies, BumpSpec::Auto)) .then(|| crate::git::history::collect(&base.repo)) .transpose()? .flatten(); let changelog_links = if changelog_links { crate::git::remote_url()? .map(|url| Linkables::AsLinks { repository_url: url.into(), }) .unwrap_or(Linkables::AsText) } else { Linkables::AsText }; Ok(Context { base, history, crates_index, bump, bump_dependencies, changelog_links, }) } } #[derive(Copy, Clone)] pub enum BumpSpec { Auto, Keep, Patch, Minor, Major, } /// In order to try dealing with https://github.com/sunng87/cargo-release/issues/224 and also to make workspace /// releases more selective. pub fn release(opts: Options, crates: Vec, bump: BumpSpec, bump_dependencies: BumpSpec) -> anyhow::Result<()> { if opts.dry_run_cargo_publish && !opts.dry_run { bail!("The --no-dry-run-cargo-publish flag is only effective without --execute") } let allow_changelog = if opts.changelog && opts.skip_tag { log::warn!("With --no-tag enabled, changelog generation will be disabled as it relies on tags to segment commit history."); false } else { opts.changelog }; let ctx = Context::new( crates, bump, bump_dependencies, allow_changelog, !opts.no_changelog_links, )?; if opts.update_crates_index { log::info!("Updating crates-io index at '{}'", ctx.crates_index.path().display()); ctx.crates_index.update()?; } else if opts.bump_when_needed { log::warn!( "Consider running with --update-crates-index to assure bumping on demand uses the latest information" ); } if !ctx.crates_index.exists() { log::warn!("Crates.io index doesn't exist. Consider using --update-crates-index to help determining if release versions are published already"); } release_depth_first(ctx, opts)?; Ok(()) } fn release_depth_first(ctx: Context, options: Options) -> anyhow::Result<()> { let meta = &ctx.base.meta; let changed_crate_names_to_publish = if options.skip_dependencies { ctx.base.crate_names.clone() } else { crate::traverse::dependencies(&ctx.base, options.verbose, options.allow_auto_publish_of_stable_crates)? }; let crates_to_publish_together = resolve_cycles_with_publish_group(meta, &changed_crate_names_to_publish, options)?; assure_working_tree_is_unchanged(options)?; if options.multi_crate_release && !changed_crate_names_to_publish.is_empty() { perform_multi_version_release(&ctx, options, meta, changed_crate_names_to_publish)?; } else { for publishee_name in changed_crate_names_to_publish .iter() .filter(|n| !crates_to_publish_together.contains(n)) { let publishee = package_by_name(meta, publishee_name)?; let (new_version, (commit_id, release_section_by_publishee)) = perform_single_release(meta, publishee, options, &ctx)?; let tag_name = git::create_version_tag( publishee, &new_version, commit_id, release_section_by_publishee .get(publishee_name.as_str()) .and_then(|s| section_to_string(s, WriteMode::Tag)), &ctx.base, options, )?; git::push_tags_and_head(tag_name.as_ref(), options)?; if let Some(message) = options .allow_changelog_github_release .then(|| { release_section_by_publishee .get(publishee_name.as_str()) .and_then(|s| section_to_string(s, WriteMode::GitHubRelease)) }) .flatten() { github::create_release(publishee, &new_version, &message, options, &ctx.base)?; } } } if !crates_to_publish_together.is_empty() { perform_multi_version_release(&ctx, options, meta, crates_to_publish_together)?; } Ok(()) } fn assure_working_tree_is_unchanged(options: Options) -> anyhow::Result<()> { if !options.allow_dirty { if let Err(err) = crate::git::assure_clean_working_tree() { if options.dry_run { log::warn!("The working tree has changes which will prevent a release with --execute unless --allow-dirty is also specified. The latter isn't recommended.") } else { return Err(err); } } } Ok(()) } fn perform_multi_version_release( ctx: &Context, options: Options, meta: &Metadata, crates_to_publish_together: Vec, ) -> anyhow::Result<()> { let mut crates_to_publish_together = crates_to_publish_together .into_iter() .map(|name| { let p = package_by_name(meta, &name)?; version::bump(p, version::select_publishee_bump_spec(&p.name, ctx), ctx, &options) .map(|v| (p, v.to_string())) }) .collect::, _>>()?; let (commit_id, release_section_by_publishee) = manifest::edit_version_and_fixup_dependent_crates_and_handle_changelog( meta, &crates_to_publish_together, options, ctx, )?; log::info!( "{} prepare releases of {}", will(options.dry_run), names_and_versions(&crates_to_publish_together) ); crates_to_publish_together.reverse(); let mut tag_names = Vec::new(); for (publishee, new_version) in crates_to_publish_together.iter().rev() { let unpublished_crates: Vec<_> = crates_to_publish_together .iter() .map(|(p, _)| p.name.to_owned()) .collect(); cargo::publish_crate(publishee, &unpublished_crates, options)?; if let Some(tag_name) = git::create_version_tag( publishee, new_version, commit_id.clone(), release_section_by_publishee .get(&publishee.name.as_str()) .and_then(|s| section_to_string(s, WriteMode::Tag)), &ctx.base, options, )? { tag_names.push(tag_name); }; } git::push_tags_and_head(tag_names.iter(), options)?; if options.allow_changelog_github_release { for (publishee, new_version) in crates_to_publish_together.iter().rev() { if let Some(message) = release_section_by_publishee .get(&publishee.name.as_str()) .and_then(|s| section_to_string(s, WriteMode::GitHubRelease)) { github::create_release(publishee, new_version, &message, options, &ctx.base)?; } } } Ok(()) } enum WriteMode { Tag, GitHubRelease, } fn section_to_string(section: &Section, mode: WriteMode) -> Option { let mut b = String::new(); section .write_to( &mut b, &changelog::write::Linkables::AsText, match mode { WriteMode::Tag => changelog::write::Components::empty(), WriteMode::GitHubRelease => changelog::write::Components::DETAIL_TAGS, }, ) .ok() .map(|_| b) } type ReleaseCommitSections<'repo, 'a> = (String, (Option>, BTreeMap<&'a str, changelog::Section>)); fn perform_single_release<'repo, 'a>( meta: &Metadata, publishee: &'a Package, options: Options, ctx: &'repo Context, ) -> anyhow::Result> { let bump_spec = version::select_publishee_bump_spec(&publishee.name, ctx); let new_version = version::bump(publishee, bump_spec, ctx, &options)?; let new_version = new_version.to_string(); let commit_id_and_changelog_sections = manifest::edit_version_and_fixup_dependent_crates_and_handle_changelog( meta, &[(publishee, new_version.clone())], options, ctx, )?; log::info!( "{} prepare release of {} v{}", will(options.dry_run), publishee.name, new_version ); cargo::publish_crate(publishee, &[], options)?; Ok((new_version, commit_id_and_changelog_sections)) } fn resolve_cycles_with_publish_group( meta: &Metadata, changed_crate_names_to_publish: &[String], options: Options, ) -> anyhow::Result> { let mut crates_to_publish_additionally_to_avoid_instability = Vec::new(); let mut publish_group = Vec::::new(); for publishee_name in changed_crate_names_to_publish.iter() { let publishee = package_by_name(meta, publishee_name)?; let cycles = workspace_members_referring_to_publishee(meta, publishee); if cycles.is_empty() { log::debug!("'{}' is cycle-free", publishee.name); } else { for Cycle { from, hops } in cycles { log::warn!( "'{}' links to '{}' {} causing publishes to never settle.", publishee.name, from.name, if hops == 1 { "directly".to_string() } else { format!("via {} hops", hops) } ); if !changed_crate_names_to_publish.contains(&from.name) { crates_to_publish_additionally_to_avoid_instability.push(from.name.clone()); } else { for name in &[&from.name, &publishee.name] { if !publish_group.contains(name) { publish_group.push(name.to_string()) } } } } } } if !crates_to_publish_additionally_to_avoid_instability.is_empty() && !options.ignore_instability { bail!( "Refusing to publish unless --ignore-instability is provided or crate(s) {} is/are included in the publish. To avoid this, don't specify versions in your dev dependencies.", crates_to_publish_additionally_to_avoid_instability.join(", ") ) } Ok(reorder_according_to_existing_order( changed_crate_names_to_publish, &publish_group, )) } fn reorder_according_to_existing_order(reference_order: &[String], names_to_order: &[String]) -> Vec { let new_order = reference_order .iter() .filter(|name| names_to_order.contains(name)) .fold(Vec::new(), |mut acc, name| { acc.push(name.clone()); acc }); assert_eq!( new_order.len(), names_to_order.len(), "the reference order must contain all items to be ordered" ); new_order } struct Cycle<'a> { from: &'a Package, hops: usize, } fn workspace_members_referring_to_publishee<'a>(meta: &'a Metadata, publishee: &Package) -> Vec> { publishee .dependencies .iter() .filter(|dep| is_dependency_with_version_requirement(dep)) // unspecified versions don't matter for publishing .filter(|dep| { dep.kind != DependencyKind::Normal && meta .workspace_members .iter() .map(|id| package_by_id(meta, id)) .any(|potential_cycle| package_eq_dependency(potential_cycle, dep)) }) .filter_map(|dep| { hops_for_dependency_to_link_back_to_publishee(meta, dep, publishee).map(|hops| Cycle { hops, from: package_by_name(meta, &dep.name).expect("package exists"), }) }) .collect() } fn hops_for_dependency_to_link_back_to_publishee<'a>( meta: &'a Metadata, source: &Dependency, destination: &Package, ) -> Option { let source = package_for_dependency(meta, source); let mut package_ids = vec![(0, &source.id)]; let mut seen = BTreeSet::new(); while let Some((level, id)) = package_ids.pop() { if !seen.insert(id) { continue; } if let Some(package) = workspace_package_by_id(meta, id) { if package .dependencies .iter() .any(|dep| package_eq_dependency(destination, dep)) { return Some(level + 1); } package_ids.extend( package .dependencies .iter() .map(|dep| (level + 1, &package_for_dependency(meta, dep).id)), ); }; } None }