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            if seen_tags.insert(tag_name) {
55                let cwd = &pkg.package_root;
56                if crate::ops::git::tag_exists(cwd, tag_name)? {
57                    let crate_name = pkg.meta.name.as_str();
58                    let _ = crate::ops::shell::log(
59                        level,
60                        format!("tag `{tag_name}` already exists (for `{crate_name}`)"),
61                    );
62                    tag_exists = true;
63                }
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            if seen_tags.insert(tag_name) {
89                let cwd = &pkg.package_root;
90                if !crate::ops::git::tag_exists(cwd, tag_name)? {
91                    let crate_name = pkg.meta.name.as_str();
92                    let _ = crate::ops::shell::log(
93                        level,
94                        format!("tag `{tag_name}` doesn't exist (for `{crate_name}`)"),
95                    );
96                    tag_missing = true;
97                }
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            if version.full_version < pkg.initial_version.full_version {
191                let crate_name = pkg.meta.name.as_str();
192                let _ = crate::ops::shell::log(
193                    level,
194                    format!(
195                        "cannot downgrade {} from {} to {}",
196                        crate_name, version.full_version, pkg.initial_version.full_version
197                    ),
198                );
199                downgrades_present = true;
200            }
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 {} since {}: {:#?}",
350                        crate_name,
351                        prior_tag_name,
352                        changed
353                    );
354                    changed_pkgs.insert(&pkg.meta.id);
355                    if changed.len() == 1 && changed[0].ends_with("Cargo.lock") {
356                        // Lock file changes don't invalidate dependencies
357                    } else {
358                        changed_pkgs.extend(pkg.dependents.iter().map(|d| &d.pkg.id));
359                    }
360                } else if changed_pkgs.contains(&pkg.meta.id) {
361                    log::debug!(
362                        "Dependency changed for {} since {}",
363                        crate_name,
364                        prior_tag_name,
365                    );
366                    changed_pkgs.insert(&pkg.meta.id);
367                    changed_pkgs.extend(pkg.dependents.iter().map(|d| &d.pkg.id));
368                } else {
369                    let _ = crate::ops::shell::warn(format!(
370                        "updating {} to {} despite no changes made since tag {}",
371                        crate_name, version.full_version_string, prior_tag_name
372                    ));
373                }
374            } else {
375                log::debug!(
376                    "cannot detect changes for {} because tag {} is missing. Try setting `--prev-tag-name <TAG>`.",
377                    crate_name,
378                    prior_tag_name
379                );
380            }
381        } else {
382            log::debug!(
383                "cannot detect changes for {} because no tag was found. Try setting `--prev-tag-name <TAG>`.",
384                crate_name,
385            );
386        }
387    }
388
389    Ok(())
390}
391
392pub fn find_shared_versions(
393    pkgs: &[plan::PackageRelease],
394) -> Result<Option<plan::Version>, crate::error::CliError> {
395    let mut is_shared = true;
396    let mut shared_versions: std::collections::HashMap<&str, &plan::Version> = Default::default();
397    for pkg in pkgs {
398        let group_name = if let Some(group_name) = pkg.config.shared_version() {
399            group_name
400        } else {
401            continue;
402        };
403        let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
404        match shared_versions.entry(group_name) {
405            std::collections::hash_map::Entry::Occupied(existing) => {
406                if version.bare_version != existing.get().bare_version {
407                    is_shared = false;
408                    let _ = crate::ops::shell::error(format!(
409                        "{} has version {}, should be {}",
410                        pkg.meta.name,
411                        version.bare_version_string,
412                        existing.get().bare_version_string
413                    ));
414                }
415            }
416            std::collections::hash_map::Entry::Vacant(vacant) => {
417                vacant.insert(version);
418            }
419        }
420    }
421    if !is_shared {
422        let _ = crate::ops::shell::error("crate versions deviated, aborting");
423        return Err(101.into());
424    }
425
426    if shared_versions.len() == 1 {
427        Ok(shared_versions.values().next().map(|s| (*s).clone()))
428    } else {
429        Ok(None)
430    }
431}
432
433pub fn consolidate_commits(
434    selected_pkgs: &[plan::PackageRelease],
435    excluded_pkgs: &[plan::PackageRelease],
436) -> Result<bool, crate::error::CliError> {
437    let mut consolidate_commits = None;
438    for pkg in selected_pkgs.iter().chain(excluded_pkgs.iter()) {
439        let current = Some(pkg.config.consolidate_commits());
440        if consolidate_commits.is_none() {
441            consolidate_commits = current;
442        } else if consolidate_commits != current {
443            let _ = crate::ops::shell::error("inconsistent `consolidate-commits` setting");
444            return Err(101.into());
445        }
446    }
447    Ok(consolidate_commits.expect("at least one package"))
448}
449
450pub fn confirm(
451    step: &str,
452    pkgs: &[plan::PackageRelease],
453    no_confirm: bool,
454    dry_run: bool,
455) -> Result<(), crate::error::CliError> {
456    if !dry_run && !no_confirm {
457        let prompt = if pkgs.len() == 1 {
458            let pkg = &pkgs[0];
459            let crate_name = pkg.meta.name.as_str();
460            let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
461            format!("{} {} {}?", step, crate_name, version.full_version_string)
462        } else {
463            use std::io::Write;
464
465            let mut buffer: Vec<u8> = vec![];
466            writeln!(&mut buffer, "{step}").unwrap();
467            for pkg in pkgs {
468                let crate_name = pkg.meta.name.as_str();
469                let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
470                writeln!(
471                    &mut buffer,
472                    "  {} {}",
473                    crate_name, version.full_version_string
474                )
475                .unwrap();
476            }
477            write!(&mut buffer, "?").unwrap();
478            String::from_utf8(buffer).expect("Only valid UTF-8 has been written")
479        };
480
481        let confirmed = crate::ops::shell::confirm(&prompt);
482        if !confirmed {
483            return Err(0.into());
484        }
485    }
486
487    Ok(())
488}
489
490pub fn finish(failed: bool, dry_run: bool) -> Result<(), crate::error::CliError> {
491    if dry_run {
492        if failed {
493            let _ =
494                crate::ops::shell::error("dry-run failed, resolve the above errors and try again.");
495            Err(101.into())
496        } else {
497            let _ =
498                crate::ops::shell::warn("aborting release due to dry run; re-run with `--execute`");
499            Ok(())
500        }
501    } else {
502        Ok(())
503    }
504}
505
506#[derive(Clone, Debug)]
507pub enum TargetVersion {
508    Relative(BumpLevel),
509    Absolute(semver::Version),
510}
511
512impl TargetVersion {
513    pub fn bump(
514        &self,
515        current: &semver::Version,
516        metadata: Option<&str>,
517    ) -> CargoResult<Option<plan::Version>> {
518        match self {
519            TargetVersion::Relative(bump_level) => {
520                let mut potential_version = current.to_owned();
521                bump_level.bump_version(&mut potential_version, metadata)?;
522                if potential_version != *current {
523                    let full_version = potential_version;
524                    let version = plan::Version::from(full_version);
525                    Ok(Some(version))
526                } else {
527                    Ok(None)
528                }
529            }
530            TargetVersion::Absolute(version) => {
531                let mut full_version = version.to_owned();
532                if full_version.build.is_empty() {
533                    if let Some(metadata) = metadata {
534                        full_version.build = semver::BuildMetadata::new(metadata)?;
535                    } else {
536                        full_version.build = current.build.clone();
537                    }
538                }
539                let version = plan::Version::from(full_version);
540                if version.bare_version != plan::Version::from(current.clone()).bare_version {
541                    Ok(Some(version))
542                } else {
543                    Ok(None)
544                }
545            }
546        }
547    }
548}
549
550impl Default for TargetVersion {
551    fn default() -> Self {
552        TargetVersion::Relative(BumpLevel::Release)
553    }
554}
555
556impl std::fmt::Display for TargetVersion {
557    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
558        match self {
559            TargetVersion::Relative(bump_level) => {
560                write!(f, "{bump_level}")
561            }
562            TargetVersion::Absolute(version) => {
563                write!(f, "{version}")
564            }
565        }
566    }
567}
568
569impl FromStr for TargetVersion {
570    type Err = String;
571
572    fn from_str(s: &str) -> Result<Self, Self::Err> {
573        if let Ok(bump_level) = BumpLevel::from_str(s) {
574            Ok(TargetVersion::Relative(bump_level))
575        } else {
576            Ok(TargetVersion::Absolute(
577                semver::Version::parse(s).map_err(|e| e.to_string())?,
578            ))
579        }
580    }
581}
582
583impl clap::builder::ValueParserFactory for TargetVersion {
584    type Parser = TargetVersionParser;
585
586    fn value_parser() -> Self::Parser {
587        TargetVersionParser
588    }
589}
590
591#[derive(Copy, Clone)]
592pub struct TargetVersionParser;
593
594impl clap::builder::TypedValueParser for TargetVersionParser {
595    type Value = TargetVersion;
596
597    fn parse_ref(
598        &self,
599        cmd: &clap::Command,
600        arg: Option<&clap::Arg>,
601        value: &std::ffi::OsStr,
602    ) -> Result<Self::Value, clap::Error> {
603        let inner_parser = TargetVersion::from_str;
604        inner_parser.parse_ref(cmd, arg, value)
605    }
606
607    fn possible_values(
608        &self,
609    ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
610        let inner_parser = clap::builder::EnumValueParser::<BumpLevel>::new();
611        inner_parser.possible_values().map(|ps| {
612            let ps = ps.collect::<Vec<_>>();
613            let ps: Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_> =
614                Box::new(ps.into_iter());
615            ps
616        })
617    }
618}
619
620#[derive(Debug, Clone, Copy, clap::ValueEnum)]
621#[value(rename_all = "kebab-case")]
622pub enum BumpLevel {
623    /// Increase the major version (x.0.0)
624    Major,
625    /// Increase the minor version (x.y.0)
626    Minor,
627    /// Increase the patch version (x.y.z)
628    Patch,
629    /// Remove the pre-version (x.y.z)
630    Release,
631    /// Increase the rc pre-version (x.y.z-rc.M)
632    Rc,
633    /// Increase the beta pre-version (x.y.z-beta.M)
634    Beta,
635    /// Increase the alpha pre-version (x.y.z-alpha.M)
636    Alpha,
637}
638
639impl std::fmt::Display for BumpLevel {
640    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
641        use clap::ValueEnum;
642
643        self.to_possible_value()
644            .expect("no values are skipped")
645            .get_name()
646            .fmt(f)
647    }
648}
649
650impl FromStr for BumpLevel {
651    type Err = String;
652
653    fn from_str(s: &str) -> Result<Self, Self::Err> {
654        use clap::ValueEnum;
655
656        for variant in Self::value_variants() {
657            if variant.to_possible_value().unwrap().matches(s, false) {
658                return Ok(*variant);
659            }
660        }
661        Err(format!("Invalid variant: {s}"))
662    }
663}
664
665impl BumpLevel {
666    pub fn bump_version(
667        self,
668        version: &mut semver::Version,
669        metadata: Option<&str>,
670    ) -> CargoResult<()> {
671        match self {
672            BumpLevel::Major => {
673                version.increment_major();
674            }
675            BumpLevel::Minor => {
676                version.increment_minor();
677            }
678            BumpLevel::Patch => {
679                if !version.is_prerelease() {
680                    version.increment_patch();
681                } else {
682                    version.pre = semver::Prerelease::EMPTY;
683                }
684            }
685            BumpLevel::Release => {
686                if version.is_prerelease() {
687                    version.pre = semver::Prerelease::EMPTY;
688                }
689            }
690            BumpLevel::Rc => {
691                version.increment_rc()?;
692            }
693            BumpLevel::Beta => {
694                version.increment_beta()?;
695            }
696            BumpLevel::Alpha => {
697                version.increment_alpha()?;
698            }
699        };
700
701        if let Some(metadata) = metadata {
702            version.metadata(metadata)?;
703        }
704
705        Ok(())
706    }
707}