Skip to main content

cargo_release/steps/
mod.rs

1use std::str::FromStr;
2
3pub mod changes;
4pub mod commit;
5pub mod config;
6pub mod hook;
7pub mod owner;
8pub mod plan;
9pub mod publish;
10pub mod push;
11pub mod release;
12pub mod replace;
13pub mod tag;
14pub mod version;
15
16use crate::error::CargoResult;
17use crate::ops::version::VersionExt as _;
18
19pub fn verify_git_is_clean(
20    path: &std::path::Path,
21    dry_run: bool,
22    level: log::Level,
23) -> Result<bool, crate::error::CliError> {
24    let mut success = true;
25    if let Some(dirty) = crate::ops::git::is_dirty(path)? {
26        let _ = crate::ops::shell::log(
27            level,
28            format!(
29                "uncommitted changes detected, please resolve before release:\n  {}",
30                dirty.join("\n  ")
31            ),
32        );
33        if level == log::Level::Error {
34            success = false;
35            if !dry_run {
36                return Err(101.into());
37            }
38        }
39    }
40    Ok(success)
41}
42
43pub fn verify_tags_missing(
44    pkgs: &[plan::PackageRelease],
45    dry_run: bool,
46    level: log::Level,
47) -> Result<bool, crate::error::CliError> {
48    let mut success = true;
49
50    let mut tag_exists = false;
51    let mut seen_tags = std::collections::HashSet::new();
52    for pkg in pkgs {
53        if let Some(tag_name) = pkg.planned_tag.as_ref()
54            && seen_tags.insert(tag_name)
55        {
56            let cwd = &pkg.package_root;
57            if crate::ops::git::tag_exists(cwd, tag_name)? {
58                let crate_name = pkg.meta.name.as_str();
59                let _ = crate::ops::shell::log(
60                    level,
61                    format!("tag `{tag_name}` already exists (for `{crate_name}`)"),
62                );
63                tag_exists = true;
64            }
65        }
66    }
67    if tag_exists && level == log::Level::Error {
68        success = false;
69        if !dry_run {
70            return Err(101.into());
71        }
72    }
73
74    Ok(success)
75}
76
77pub fn verify_tags_exist(
78    pkgs: &[plan::PackageRelease],
79    dry_run: bool,
80    level: log::Level,
81) -> Result<bool, crate::error::CliError> {
82    let mut success = true;
83
84    let mut tag_missing = false;
85    let mut seen_tags = std::collections::HashSet::new();
86    for pkg in pkgs {
87        if let Some(tag_name) = pkg.planned_tag.as_ref()
88            && seen_tags.insert(tag_name)
89        {
90            let cwd = &pkg.package_root;
91            if !crate::ops::git::tag_exists(cwd, tag_name)? {
92                let crate_name = pkg.meta.name.as_str();
93                let _ = crate::ops::shell::log(
94                    level,
95                    format!("tag `{tag_name}` doesn't exist (for `{crate_name}`)"),
96                );
97                tag_missing = true;
98            }
99        }
100    }
101    if tag_missing && level == log::Level::Error {
102        success = false;
103        if !dry_run {
104            return Err(101.into());
105        }
106    }
107
108    Ok(success)
109}
110
111pub fn verify_git_branch(
112    path: &std::path::Path,
113    ws_config: &crate::config::Config,
114    dry_run: bool,
115    level: log::Level,
116) -> Result<bool, crate::error::CliError> {
117    use itertools::Itertools;
118
119    let mut success = true;
120
121    let branch = crate::ops::git::current_branch(path)?;
122    let mut good_branches = ignore::gitignore::GitignoreBuilder::new(".");
123    for pattern in ws_config.allow_branch() {
124        good_branches.add_line(None, pattern)?;
125    }
126    let good_branches = good_branches.build()?;
127    let good_branch_match = good_branches.matched_path_or_any_parents(&branch, false);
128    if !good_branch_match.is_ignore() {
129        let allowed = ws_config
130            .allow_branch()
131            .map(|b| format!("`{b}`"))
132            .join(", ");
133        let _ = crate::ops::shell::log(
134            level,
135            format!(
136                "cannot release from branch `{branch}` as it doesn't match {allowed}; either switch to an allowed branch or add this branch to `allow-branch`",
137            ),
138        );
139        log::trace!("due to {good_branch_match:?}");
140        if level == log::Level::Error {
141            success = false;
142            if !dry_run {
143                return Err(101.into());
144            }
145        }
146    }
147
148    Ok(success)
149}
150
151pub fn verify_if_behind(
152    path: &std::path::Path,
153    ws_config: &crate::config::Config,
154    dry_run: bool,
155    level: log::Level,
156) -> Result<bool, crate::error::CliError> {
157    let mut success = true;
158
159    // If we are not pushing, we are not behind our push target.
160    if !ws_config.push() {
161        return Ok(success);
162    }
163
164    let git_remote = ws_config.push_remote();
165    let branch = crate::ops::git::current_branch(path)?;
166    crate::ops::git::fetch(path, git_remote, &branch)?;
167    if crate::ops::git::is_behind_remote(path, git_remote, &branch)? {
168        let title = format!("{branch} is behind {git_remote}/{branch}");
169        if let Some(level) = crate::ops::shell::level(level) {
170            let report = &[
171                annotate_snippets::Group::with_title(level.primary_title(title)),
172                annotate_snippets::Group::with_title(
173                    annotate_snippets::Level::HELP.primary_title(format!("to update your release branch, run `git pull --rebase {git_remote} {branch}`")),
174                ),
175            ];
176            let _ = crate::ops::shell::print_report(report);
177        } else {
178            let _ = crate::ops::shell::log(level, title);
179        }
180        if level == log::Level::Error {
181            success = false;
182            if !dry_run {
183                return Err(101.into());
184            }
185        }
186    }
187
188    Ok(success)
189}
190
191pub fn verify_monotonically_increasing(
192    pkgs: &[plan::PackageRelease],
193    dry_run: bool,
194    level: log::Level,
195) -> Result<bool, crate::error::CliError> {
196    let mut success = true;
197
198    let mut downgrades_present = false;
199    for pkg in pkgs {
200        if let Some(version) = pkg.planned_version.as_ref()
201            && version.full_version < pkg.initial_version.full_version
202        {
203            let crate_name = pkg.meta.name.as_str();
204            let _ = crate::ops::shell::log(
205                level,
206                format!(
207                    "cannot downgrade {} from {} to {}",
208                    crate_name, version.full_version, pkg.initial_version.full_version
209                ),
210            );
211            downgrades_present = true;
212        }
213    }
214    if downgrades_present && level == log::Level::Error {
215        success = false;
216        if !dry_run {
217            return Err(101.into());
218        }
219    }
220
221    Ok(success)
222}
223
224pub fn verify_rate_limit(
225    pkgs: &[plan::PackageRelease],
226    index: &mut crate::ops::index::CratesIoIndex,
227    rate_limit: &crate::config::RateLimit,
228    dry_run: bool,
229    level: log::Level,
230) -> Result<bool, crate::error::CliError> {
231    let mut success = true;
232
233    // "It's not particularly secret, we just don't publish it other than in the code because
234    // it's subject to change. The responses from the rate limited requests on when to try
235    // again contain the most accurate information."
236    let mut new = 0;
237    let mut existing = 0;
238    for pkg in pkgs {
239        // Note: these rate limits are only known for default registry
240        if pkg.config.registry().is_none() && pkg.config.publish() {
241            let crate_name = pkg.meta.name.as_str();
242            if index.has_krate(None, crate_name, pkg.config.certs_source())? {
243                existing += 1;
244            } else {
245                new += 1;
246            }
247        }
248    }
249
250    if rate_limit.new_packages() < new {
251        // "The rate limit for creating new crates is 1 crate every 10 minutes, with a burst of 5 crates."
252        success = false;
253        let _ = crate::ops::shell::log(
254            level,
255            format!(
256                "attempting to publish {} new crates which is above the rate limit: {}",
257                new,
258                rate_limit.new_packages()
259            ),
260        );
261    }
262
263    if rate_limit.existing_packages() < existing {
264        // "The rate limit for new versions of existing crates is 1 per minute, with a burst of 30 crates, so when releasing new versions of these crates, you shouldn't hit the limit."
265        success = false;
266        let _ = crate::ops::shell::log(
267            level,
268            format!(
269                "attempting to publish {} existing crates which is above the rate limit: {}",
270                existing,
271                rate_limit.existing_packages()
272            ),
273        );
274    }
275
276    if !success && level == log::Level::Error && !dry_run {
277        return Err(101.into());
278    }
279
280    Ok(success)
281}
282
283pub fn verify_metadata(
284    pkgs: &[plan::PackageRelease],
285    dry_run: bool,
286    level: log::Level,
287) -> Result<bool, crate::error::CliError> {
288    let mut success = true;
289
290    for pkg in pkgs {
291        if !pkg.config.publish() {
292            continue;
293        }
294        let mut missing = Vec::new();
295
296        // General cargo rules
297        if pkg
298            .meta
299            .description
300            .as_deref()
301            .unwrap_or_default()
302            .is_empty()
303        {
304            missing.push("description");
305        }
306        if pkg.meta.license.as_deref().unwrap_or_default().is_empty()
307            && pkg.meta.license_file.is_none()
308        {
309            missing.push("license || license-file");
310        }
311        if pkg
312            .meta
313            .documentation
314            .as_deref()
315            .unwrap_or_default()
316            .is_empty()
317            && pkg.meta.homepage.as_deref().unwrap_or_default().is_empty()
318            && pkg
319                .meta
320                .repository
321                .as_deref()
322                .unwrap_or_default()
323                .is_empty()
324        {
325            missing.push("documentation || homepage || repository");
326        }
327
328        if !missing.is_empty() {
329            let _ = crate::ops::shell::log(
330                level,
331                format!(
332                    "{} is missing the following fields:\n  {}",
333                    pkg.meta.name,
334                    missing.join("\n  ")
335                ),
336            );
337            success = false;
338        }
339    }
340
341    if !success && level == log::Level::Error && !dry_run {
342        return Err(101.into());
343    }
344
345    Ok(success)
346}
347
348pub fn warn_changed(
349    ws_meta: &cargo_metadata::Metadata,
350    pkgs: &[plan::PackageRelease],
351) -> Result<(), crate::error::CliError> {
352    let mut changed_pkgs = std::collections::HashSet::new();
353    for pkg in pkgs {
354        let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
355        let crate_name = pkg.meta.name.as_str();
356        if let Some(prior_tag_name) = &pkg.prior_tag {
357            if let Some(changed) = version::changed_since(ws_meta, pkg, prior_tag_name) {
358                if !changed.is_empty() {
359                    log::debug!(
360                        "Files changed in {crate_name} since {prior_tag_name}: {changed:#?}"
361                    );
362                    changed_pkgs.insert(&pkg.meta.id);
363                    if changed.len() == 1 && changed[0].ends_with("Cargo.lock") {
364                        // Lock file changes don't invalidate dependencies
365                    } else {
366                        changed_pkgs.extend(pkg.dependents.iter().map(|d| &d.pkg.id));
367                    }
368                } else if changed_pkgs.contains(&pkg.meta.id) {
369                    log::debug!("Dependency changed for {crate_name} since {prior_tag_name}",);
370                    changed_pkgs.insert(&pkg.meta.id);
371                    changed_pkgs.extend(pkg.dependents.iter().map(|d| &d.pkg.id));
372                } else {
373                    let _ = crate::ops::shell::warn(format!(
374                        "updating {} to {} despite no changes made since tag {}",
375                        crate_name, version.full_version_string, prior_tag_name
376                    ));
377                }
378            } else {
379                log::debug!(
380                    "cannot detect changes for {crate_name} because tag {prior_tag_name} is missing. Try setting `--prev-tag-name <TAG>`."
381                );
382            }
383        } else {
384            log::debug!(
385                "cannot detect changes for {crate_name} because no tag was found. Try setting `--prev-tag-name <TAG>`.",
386            );
387        }
388    }
389
390    Ok(())
391}
392
393pub fn find_shared_versions(
394    pkgs: &[plan::PackageRelease],
395) -> Result<Option<plan::Version>, crate::error::CliError> {
396    let mut is_shared = true;
397    let mut shared_versions: std::collections::HashMap<&str, &plan::Version> = Default::default();
398    for pkg in pkgs {
399        let group_name = if let Some(group_name) = pkg.config.shared_version() {
400            group_name
401        } else {
402            continue;
403        };
404        let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
405        match shared_versions.entry(group_name) {
406            std::collections::hash_map::Entry::Occupied(existing) => {
407                if version.bare_version != existing.get().bare_version {
408                    is_shared = false;
409                    let _ = crate::ops::shell::error(format!(
410                        "{} has version {}, should be {}",
411                        pkg.meta.name,
412                        version.bare_version_string,
413                        existing.get().bare_version_string
414                    ));
415                }
416            }
417            std::collections::hash_map::Entry::Vacant(vacant) => {
418                vacant.insert(version);
419            }
420        }
421    }
422    if !is_shared {
423        let _ = crate::ops::shell::error("crate versions deviated, aborting");
424        return Err(101.into());
425    }
426
427    if shared_versions.len() == 1 {
428        Ok(shared_versions.values().next().map(|s| (*s).clone()))
429    } else {
430        Ok(None)
431    }
432}
433
434pub fn consolidate_commits(
435    selected_pkgs: &[plan::PackageRelease],
436    excluded_pkgs: &[plan::PackageRelease],
437) -> Result<bool, crate::error::CliError> {
438    let mut consolidate_commits = None;
439    for pkg in selected_pkgs.iter().chain(excluded_pkgs.iter()) {
440        let current = Some(pkg.config.consolidate_commits());
441        if consolidate_commits.is_none() {
442            consolidate_commits = current;
443        } else if consolidate_commits != current {
444            let _ = crate::ops::shell::error("inconsistent `consolidate-commits` setting");
445            return Err(101.into());
446        }
447    }
448    Ok(consolidate_commits.expect("at least one package"))
449}
450
451pub fn confirm(
452    step: &str,
453    pkgs: &[plan::PackageRelease],
454    no_confirm: bool,
455    dry_run: bool,
456) -> Result<(), crate::error::CliError> {
457    if !dry_run && !no_confirm {
458        let prompt = if pkgs.len() == 1 {
459            let pkg = &pkgs[0];
460            let crate_name = pkg.meta.name.as_str();
461            let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
462            format!("{} {} {}?", step, crate_name, version.full_version_string)
463        } else {
464            use std::io::Write;
465
466            let mut buffer: Vec<u8> = vec![];
467            writeln!(&mut buffer, "{step}").unwrap();
468            for pkg in pkgs {
469                let crate_name = pkg.meta.name.as_str();
470                let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
471                writeln!(
472                    &mut buffer,
473                    "  {} {}",
474                    crate_name, version.full_version_string
475                )
476                .unwrap();
477            }
478            write!(&mut buffer, "?").unwrap();
479            String::from_utf8(buffer).expect("Only valid UTF-8 has been written")
480        };
481
482        let confirmed = crate::ops::shell::confirm(&prompt);
483        if !confirmed {
484            return Err(0.into());
485        }
486    }
487
488    Ok(())
489}
490
491pub fn finish(failed: bool, dry_run: bool) -> Result<(), crate::error::CliError> {
492    if dry_run {
493        if failed {
494            let _ =
495                crate::ops::shell::error("dry-run failed, resolve the above errors and try again.");
496            Err(101.into())
497        } else {
498            let _ =
499                crate::ops::shell::warn("aborting release due to dry run; re-run with `--execute`");
500            Ok(())
501        }
502    } else {
503        Ok(())
504    }
505}
506
507#[derive(Clone, Debug)]
508pub enum TargetVersion {
509    Relative(BumpLevel),
510    Absolute(semver::Version),
511}
512
513impl TargetVersion {
514    pub fn bump(
515        &self,
516        current: &semver::Version,
517        metadata: Option<&str>,
518    ) -> CargoResult<Option<plan::Version>> {
519        match self {
520            TargetVersion::Relative(bump_level) => {
521                let mut potential_version = current.to_owned();
522                bump_level.bump_version(&mut potential_version, metadata)?;
523                if potential_version != *current {
524                    let full_version = potential_version;
525                    let version = plan::Version::from(full_version);
526                    Ok(Some(version))
527                } else {
528                    Ok(None)
529                }
530            }
531            TargetVersion::Absolute(version) => {
532                let mut full_version = version.to_owned();
533                if full_version.build.is_empty() {
534                    if let Some(metadata) = metadata {
535                        full_version.build = semver::BuildMetadata::new(metadata)?;
536                    } else {
537                        full_version.build = current.build.clone();
538                    }
539                }
540                let version = plan::Version::from(full_version);
541                if version.bare_version != plan::Version::from(current.clone()).bare_version {
542                    Ok(Some(version))
543                } else {
544                    Ok(None)
545                }
546            }
547        }
548    }
549}
550
551impl Default for TargetVersion {
552    fn default() -> Self {
553        TargetVersion::Relative(BumpLevel::Release)
554    }
555}
556
557impl std::fmt::Display for TargetVersion {
558    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
559        match self {
560            TargetVersion::Relative(bump_level) => {
561                write!(f, "{bump_level}")
562            }
563            TargetVersion::Absolute(version) => {
564                write!(f, "{version}")
565            }
566        }
567    }
568}
569
570impl FromStr for TargetVersion {
571    type Err = String;
572
573    fn from_str(s: &str) -> Result<Self, Self::Err> {
574        if let Ok(bump_level) = BumpLevel::from_str(s) {
575            Ok(TargetVersion::Relative(bump_level))
576        } else {
577            Ok(TargetVersion::Absolute(
578                semver::Version::parse(s).map_err(|e| e.to_string())?,
579            ))
580        }
581    }
582}
583
584impl clap::builder::ValueParserFactory for TargetVersion {
585    type Parser = TargetVersionParser;
586
587    fn value_parser() -> Self::Parser {
588        TargetVersionParser
589    }
590}
591
592#[derive(Copy, Clone)]
593pub struct TargetVersionParser;
594
595impl clap::builder::TypedValueParser for TargetVersionParser {
596    type Value = TargetVersion;
597
598    fn parse_ref(
599        &self,
600        cmd: &clap::Command,
601        arg: Option<&clap::Arg>,
602        value: &std::ffi::OsStr,
603    ) -> Result<Self::Value, clap::Error> {
604        let inner_parser = TargetVersion::from_str;
605        inner_parser.parse_ref(cmd, arg, value)
606    }
607
608    fn possible_values(
609        &self,
610    ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
611        let inner_parser = clap::builder::EnumValueParser::<BumpLevel>::new();
612        inner_parser.possible_values().map(|ps| {
613            let ps = ps.collect::<Vec<_>>();
614            let ps: Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_> =
615                Box::new(ps.into_iter());
616            ps
617        })
618    }
619}
620
621#[derive(Debug, Clone, Copy, clap::ValueEnum)]
622#[value(rename_all = "kebab-case")]
623pub enum BumpLevel {
624    /// Increase the major version (x.0.0)
625    Major,
626    /// Increase the minor version (x.y.0)
627    Minor,
628    /// Increase the patch version (x.y.z)
629    Patch,
630    /// Remove the pre-version (x.y.z)
631    Release,
632    /// Increase the rc pre-version (x.y.z-rc.M)
633    Rc,
634    /// Increase the beta pre-version (x.y.z-beta.M)
635    Beta,
636    /// Increase the alpha pre-version (x.y.z-alpha.M)
637    Alpha,
638}
639
640impl std::fmt::Display for BumpLevel {
641    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
642        use clap::ValueEnum;
643
644        self.to_possible_value()
645            .expect("no values are skipped")
646            .get_name()
647            .fmt(f)
648    }
649}
650
651impl FromStr for BumpLevel {
652    type Err = String;
653
654    fn from_str(s: &str) -> Result<Self, Self::Err> {
655        use clap::ValueEnum;
656
657        for variant in Self::value_variants() {
658            if variant.to_possible_value().unwrap().matches(s, false) {
659                return Ok(*variant);
660            }
661        }
662        Err(format!("Invalid variant: {s}"))
663    }
664}
665
666impl BumpLevel {
667    pub fn bump_version(
668        self,
669        version: &mut semver::Version,
670        metadata: Option<&str>,
671    ) -> CargoResult<()> {
672        match self {
673            BumpLevel::Major => {
674                version.increment_major();
675            }
676            BumpLevel::Minor => {
677                version.increment_minor();
678            }
679            BumpLevel::Patch => {
680                if !version.is_prerelease() {
681                    version.increment_patch();
682                } else {
683                    version.pre = semver::Prerelease::EMPTY;
684                }
685            }
686            BumpLevel::Release => {
687                if version.is_prerelease() {
688                    version.pre = semver::Prerelease::EMPTY;
689                }
690            }
691            BumpLevel::Rc => {
692                version.increment_rc()?;
693            }
694            BumpLevel::Beta => {
695                version.increment_beta()?;
696            }
697            BumpLevel::Alpha => {
698                version.increment_alpha()?;
699            }
700        };
701
702        if let Some(metadata) = metadata {
703            version.metadata(metadata)?;
704        }
705
706        Ok(())
707    }
708}