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 _ = crate::ops::shell::log(level, format!("{branch} is behind {git_remote}/{branch}"));
169        if level == log::Level::Error {
170            success = false;
171            if !dry_run {
172                return Err(101.into());
173            }
174        }
175    }
176
177    Ok(success)
178}
179
180pub fn verify_monotonically_increasing(
181    pkgs: &[plan::PackageRelease],
182    dry_run: bool,
183    level: log::Level,
184) -> Result<bool, crate::error::CliError> {
185    let mut success = true;
186
187    let mut downgrades_present = false;
188    for pkg in pkgs {
189        if let Some(version) = pkg.planned_version.as_ref()
190            && version.full_version < pkg.initial_version.full_version
191        {
192            let crate_name = pkg.meta.name.as_str();
193            let _ = crate::ops::shell::log(
194                level,
195                format!(
196                    "cannot downgrade {} from {} to {}",
197                    crate_name, version.full_version, pkg.initial_version.full_version
198                ),
199            );
200            downgrades_present = true;
201        }
202    }
203    if downgrades_present && level == log::Level::Error {
204        success = false;
205        if !dry_run {
206            return Err(101.into());
207        }
208    }
209
210    Ok(success)
211}
212
213pub fn verify_rate_limit(
214    pkgs: &[plan::PackageRelease],
215    index: &mut crate::ops::index::CratesIoIndex,
216    rate_limit: &crate::config::RateLimit,
217    dry_run: bool,
218    level: log::Level,
219) -> Result<bool, crate::error::CliError> {
220    let mut success = true;
221
222    // "It's not particularly secret, we just don't publish it other than in the code because
223    // it's subject to change. The responses from the rate limited requests on when to try
224    // again contain the most accurate information."
225    let mut new = 0;
226    let mut existing = 0;
227    for pkg in pkgs {
228        // Note: these rate limits are only known for default registry
229        if pkg.config.registry().is_none() && pkg.config.publish() {
230            let crate_name = pkg.meta.name.as_str();
231            if index.has_krate(None, crate_name, pkg.config.certs_source())? {
232                existing += 1;
233            } else {
234                new += 1;
235            }
236        }
237    }
238
239    if rate_limit.new_packages() < new {
240        // "The rate limit for creating new crates is 1 crate every 10 minutes, with a burst of 5 crates."
241        success = false;
242        let _ = crate::ops::shell::log(
243            level,
244            format!(
245                "attempting to publish {} new crates which is above the rate limit: {}",
246                new,
247                rate_limit.new_packages()
248            ),
249        );
250    }
251
252    if rate_limit.existing_packages() < existing {
253        // "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."
254        success = false;
255        let _ = crate::ops::shell::log(
256            level,
257            format!(
258                "attempting to publish {} existing crates which is above the rate limit: {}",
259                existing,
260                rate_limit.existing_packages()
261            ),
262        );
263    }
264
265    if !success && level == log::Level::Error && !dry_run {
266        return Err(101.into());
267    }
268
269    Ok(success)
270}
271
272pub fn verify_metadata(
273    pkgs: &[plan::PackageRelease],
274    dry_run: bool,
275    level: log::Level,
276) -> Result<bool, crate::error::CliError> {
277    let mut success = true;
278
279    for pkg in pkgs {
280        if !pkg.config.publish() {
281            continue;
282        }
283        let mut missing = Vec::new();
284
285        // General cargo rules
286        if pkg
287            .meta
288            .description
289            .as_deref()
290            .unwrap_or_default()
291            .is_empty()
292        {
293            missing.push("description");
294        }
295        if pkg.meta.license.as_deref().unwrap_or_default().is_empty()
296            && pkg.meta.license_file.is_none()
297        {
298            missing.push("license || license-file");
299        }
300        if pkg
301            .meta
302            .documentation
303            .as_deref()
304            .unwrap_or_default()
305            .is_empty()
306            && pkg.meta.homepage.as_deref().unwrap_or_default().is_empty()
307            && pkg
308                .meta
309                .repository
310                .as_deref()
311                .unwrap_or_default()
312                .is_empty()
313        {
314            missing.push("documentation || homepage || repository");
315        }
316
317        if !missing.is_empty() {
318            let _ = crate::ops::shell::log(
319                level,
320                format!(
321                    "{} is missing the following fields:\n  {}",
322                    pkg.meta.name,
323                    missing.join("\n  ")
324                ),
325            );
326            success = false;
327        }
328    }
329
330    if !success && level == log::Level::Error && !dry_run {
331        return Err(101.into());
332    }
333
334    Ok(success)
335}
336
337pub fn warn_changed(
338    ws_meta: &cargo_metadata::Metadata,
339    pkgs: &[plan::PackageRelease],
340) -> Result<(), crate::error::CliError> {
341    let mut changed_pkgs = std::collections::HashSet::new();
342    for pkg in pkgs {
343        let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
344        let crate_name = pkg.meta.name.as_str();
345        if let Some(prior_tag_name) = &pkg.prior_tag {
346            if let Some(changed) = version::changed_since(ws_meta, pkg, prior_tag_name) {
347                if !changed.is_empty() {
348                    log::debug!(
349                        "Files changed in {crate_name} since {prior_tag_name}: {changed:#?}"
350                    );
351                    changed_pkgs.insert(&pkg.meta.id);
352                    if changed.len() == 1 && changed[0].ends_with("Cargo.lock") {
353                        // Lock file changes don't invalidate dependencies
354                    } else {
355                        changed_pkgs.extend(pkg.dependents.iter().map(|d| &d.pkg.id));
356                    }
357                } else if changed_pkgs.contains(&pkg.meta.id) {
358                    log::debug!("Dependency changed for {crate_name} since {prior_tag_name}",);
359                    changed_pkgs.insert(&pkg.meta.id);
360                    changed_pkgs.extend(pkg.dependents.iter().map(|d| &d.pkg.id));
361                } else {
362                    let _ = crate::ops::shell::warn(format!(
363                        "updating {} to {} despite no changes made since tag {}",
364                        crate_name, version.full_version_string, prior_tag_name
365                    ));
366                }
367            } else {
368                log::debug!(
369                    "cannot detect changes for {crate_name} because tag {prior_tag_name} is missing. Try setting `--prev-tag-name <TAG>`."
370                );
371            }
372        } else {
373            log::debug!(
374                "cannot detect changes for {crate_name} because no tag was found. Try setting `--prev-tag-name <TAG>`.",
375            );
376        }
377    }
378
379    Ok(())
380}
381
382pub fn find_shared_versions(
383    pkgs: &[plan::PackageRelease],
384) -> Result<Option<plan::Version>, crate::error::CliError> {
385    let mut is_shared = true;
386    let mut shared_versions: std::collections::HashMap<&str, &plan::Version> = Default::default();
387    for pkg in pkgs {
388        let group_name = if let Some(group_name) = pkg.config.shared_version() {
389            group_name
390        } else {
391            continue;
392        };
393        let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
394        match shared_versions.entry(group_name) {
395            std::collections::hash_map::Entry::Occupied(existing) => {
396                if version.bare_version != existing.get().bare_version {
397                    is_shared = false;
398                    let _ = crate::ops::shell::error(format!(
399                        "{} has version {}, should be {}",
400                        pkg.meta.name,
401                        version.bare_version_string,
402                        existing.get().bare_version_string
403                    ));
404                }
405            }
406            std::collections::hash_map::Entry::Vacant(vacant) => {
407                vacant.insert(version);
408            }
409        }
410    }
411    if !is_shared {
412        let _ = crate::ops::shell::error("crate versions deviated, aborting");
413        return Err(101.into());
414    }
415
416    if shared_versions.len() == 1 {
417        Ok(shared_versions.values().next().map(|s| (*s).clone()))
418    } else {
419        Ok(None)
420    }
421}
422
423pub fn consolidate_commits(
424    selected_pkgs: &[plan::PackageRelease],
425    excluded_pkgs: &[plan::PackageRelease],
426) -> Result<bool, crate::error::CliError> {
427    let mut consolidate_commits = None;
428    for pkg in selected_pkgs.iter().chain(excluded_pkgs.iter()) {
429        let current = Some(pkg.config.consolidate_commits());
430        if consolidate_commits.is_none() {
431            consolidate_commits = current;
432        } else if consolidate_commits != current {
433            let _ = crate::ops::shell::error("inconsistent `consolidate-commits` setting");
434            return Err(101.into());
435        }
436    }
437    Ok(consolidate_commits.expect("at least one package"))
438}
439
440pub fn confirm(
441    step: &str,
442    pkgs: &[plan::PackageRelease],
443    no_confirm: bool,
444    dry_run: bool,
445) -> Result<(), crate::error::CliError> {
446    if !dry_run && !no_confirm {
447        let prompt = if pkgs.len() == 1 {
448            let pkg = &pkgs[0];
449            let crate_name = pkg.meta.name.as_str();
450            let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
451            format!("{} {} {}?", step, crate_name, version.full_version_string)
452        } else {
453            use std::io::Write;
454
455            let mut buffer: Vec<u8> = vec![];
456            writeln!(&mut buffer, "{step}").unwrap();
457            for pkg in pkgs {
458                let crate_name = pkg.meta.name.as_str();
459                let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
460                writeln!(
461                    &mut buffer,
462                    "  {} {}",
463                    crate_name, version.full_version_string
464                )
465                .unwrap();
466            }
467            write!(&mut buffer, "?").unwrap();
468            String::from_utf8(buffer).expect("Only valid UTF-8 has been written")
469        };
470
471        let confirmed = crate::ops::shell::confirm(&prompt);
472        if !confirmed {
473            return Err(0.into());
474        }
475    }
476
477    Ok(())
478}
479
480pub fn finish(failed: bool, dry_run: bool) -> Result<(), crate::error::CliError> {
481    if dry_run {
482        if failed {
483            let _ =
484                crate::ops::shell::error("dry-run failed, resolve the above errors and try again.");
485            Err(101.into())
486        } else {
487            let _ =
488                crate::ops::shell::warn("aborting release due to dry run; re-run with `--execute`");
489            Ok(())
490        }
491    } else {
492        Ok(())
493    }
494}
495
496#[derive(Clone, Debug)]
497pub enum TargetVersion {
498    Relative(BumpLevel),
499    Absolute(semver::Version),
500}
501
502impl TargetVersion {
503    pub fn bump(
504        &self,
505        current: &semver::Version,
506        metadata: Option<&str>,
507    ) -> CargoResult<Option<plan::Version>> {
508        match self {
509            TargetVersion::Relative(bump_level) => {
510                let mut potential_version = current.to_owned();
511                bump_level.bump_version(&mut potential_version, metadata)?;
512                if potential_version != *current {
513                    let full_version = potential_version;
514                    let version = plan::Version::from(full_version);
515                    Ok(Some(version))
516                } else {
517                    Ok(None)
518                }
519            }
520            TargetVersion::Absolute(version) => {
521                let mut full_version = version.to_owned();
522                if full_version.build.is_empty() {
523                    if let Some(metadata) = metadata {
524                        full_version.build = semver::BuildMetadata::new(metadata)?;
525                    } else {
526                        full_version.build = current.build.clone();
527                    }
528                }
529                let version = plan::Version::from(full_version);
530                if version.bare_version != plan::Version::from(current.clone()).bare_version {
531                    Ok(Some(version))
532                } else {
533                    Ok(None)
534                }
535            }
536        }
537    }
538}
539
540impl Default for TargetVersion {
541    fn default() -> Self {
542        TargetVersion::Relative(BumpLevel::Release)
543    }
544}
545
546impl std::fmt::Display for TargetVersion {
547    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
548        match self {
549            TargetVersion::Relative(bump_level) => {
550                write!(f, "{bump_level}")
551            }
552            TargetVersion::Absolute(version) => {
553                write!(f, "{version}")
554            }
555        }
556    }
557}
558
559impl FromStr for TargetVersion {
560    type Err = String;
561
562    fn from_str(s: &str) -> Result<Self, Self::Err> {
563        if let Ok(bump_level) = BumpLevel::from_str(s) {
564            Ok(TargetVersion::Relative(bump_level))
565        } else {
566            Ok(TargetVersion::Absolute(
567                semver::Version::parse(s).map_err(|e| e.to_string())?,
568            ))
569        }
570    }
571}
572
573impl clap::builder::ValueParserFactory for TargetVersion {
574    type Parser = TargetVersionParser;
575
576    fn value_parser() -> Self::Parser {
577        TargetVersionParser
578    }
579}
580
581#[derive(Copy, Clone)]
582pub struct TargetVersionParser;
583
584impl clap::builder::TypedValueParser for TargetVersionParser {
585    type Value = TargetVersion;
586
587    fn parse_ref(
588        &self,
589        cmd: &clap::Command,
590        arg: Option<&clap::Arg>,
591        value: &std::ffi::OsStr,
592    ) -> Result<Self::Value, clap::Error> {
593        let inner_parser = TargetVersion::from_str;
594        inner_parser.parse_ref(cmd, arg, value)
595    }
596
597    fn possible_values(
598        &self,
599    ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
600        let inner_parser = clap::builder::EnumValueParser::<BumpLevel>::new();
601        inner_parser.possible_values().map(|ps| {
602            let ps = ps.collect::<Vec<_>>();
603            let ps: Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_> =
604                Box::new(ps.into_iter());
605            ps
606        })
607    }
608}
609
610#[derive(Debug, Clone, Copy, clap::ValueEnum)]
611#[value(rename_all = "kebab-case")]
612pub enum BumpLevel {
613    /// Increase the major version (x.0.0)
614    Major,
615    /// Increase the minor version (x.y.0)
616    Minor,
617    /// Increase the patch version (x.y.z)
618    Patch,
619    /// Remove the pre-version (x.y.z)
620    Release,
621    /// Increase the rc pre-version (x.y.z-rc.M)
622    Rc,
623    /// Increase the beta pre-version (x.y.z-beta.M)
624    Beta,
625    /// Increase the alpha pre-version (x.y.z-alpha.M)
626    Alpha,
627}
628
629impl std::fmt::Display for BumpLevel {
630    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
631        use clap::ValueEnum;
632
633        self.to_possible_value()
634            .expect("no values are skipped")
635            .get_name()
636            .fmt(f)
637    }
638}
639
640impl FromStr for BumpLevel {
641    type Err = String;
642
643    fn from_str(s: &str) -> Result<Self, Self::Err> {
644        use clap::ValueEnum;
645
646        for variant in Self::value_variants() {
647            if variant.to_possible_value().unwrap().matches(s, false) {
648                return Ok(*variant);
649            }
650        }
651        Err(format!("Invalid variant: {s}"))
652    }
653}
654
655impl BumpLevel {
656    pub fn bump_version(
657        self,
658        version: &mut semver::Version,
659        metadata: Option<&str>,
660    ) -> CargoResult<()> {
661        match self {
662            BumpLevel::Major => {
663                version.increment_major();
664            }
665            BumpLevel::Minor => {
666                version.increment_minor();
667            }
668            BumpLevel::Patch => {
669                if !version.is_prerelease() {
670                    version.increment_patch();
671                } else {
672                    version.pre = semver::Prerelease::EMPTY;
673                }
674            }
675            BumpLevel::Release => {
676                if version.is_prerelease() {
677                    version.pre = semver::Prerelease::EMPTY;
678                }
679            }
680            BumpLevel::Rc => {
681                version.increment_rc()?;
682            }
683            BumpLevel::Beta => {
684                version.increment_beta()?;
685            }
686            BumpLevel::Alpha => {
687                version.increment_alpha()?;
688            }
689        };
690
691        if let Some(metadata) = metadata {
692            version.metadata(metadata)?;
693        }
694
695        Ok(())
696    }
697}