xtask/cmd/release/
update.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
2use std::fmt::Write;
3
4use anyhow::Context;
5use cargo_metadata::camino::{Utf8Path, Utf8PathBuf};
6use cargo_metadata::semver::Version;
7use cargo_metadata::{DependencyKind, semver};
8use serde::Deserialize as _;
9use serde::de::IntoDeserializer;
10use serde_derive::{Deserialize, Serialize};
11use toml_edit::DocumentMut;
12
13use super::check::CheckRun;
14use super::utils::VersionBump;
15use crate::utils::git_workdir_clean;
16
17#[derive(Debug, Clone, clap::Parser)]
18pub struct Update {
19    /// Concurrency to run at. By default, this is the total number of cpus on the host.
20    #[arg(long, default_value_t = num_cpus::get())]
21    concurrency: usize,
22    /// Run the command without modifying any files on disk
23    #[arg(long)]
24    dry_run: bool,
25    /// Allow the command to execute even if there are uncomitted changes in the workspace
26    #[arg(long)]
27    allow_dirty: bool,
28    /// Packages to include in the check
29    /// by default all packages are included
30    #[arg(long = "package", short = 'p')]
31    packages: Vec<String>,
32    /// Only generate the changelogs, not the version bumps.
33    #[arg(long)]
34    changelogs_only: bool,
35}
36
37impl Update {
38    pub fn run(self) -> anyhow::Result<()> {
39        if !self.allow_dirty {
40            git_workdir_clean()?;
41        }
42
43        let metadata = crate::utils::metadata()?;
44
45        let check_run = CheckRun::new(&metadata, &self.packages).context("check run")?;
46
47        let mut change_fragments = std::fs::read_dir(metadata.workspace_root.join("changes.d"))?
48            .filter_map(|entry| entry.ok())
49            .filter_map(|entry| {
50                let entry_path = entry.path();
51                if entry_path.is_file() {
52                    let file_name = entry_path.file_name()?.to_str()?;
53                    file_name.strip_prefix("pr-")?.strip_suffix(".toml")?.parse().ok()
54                } else {
55                    None
56                }
57            })
58            .try_fold(BTreeMap::new(), |mut fragments, pr_number| {
59                let fragment = Fragment::new(pr_number, &metadata.workspace_root)?;
60
61                fragments.insert(pr_number, fragment);
62
63                anyhow::Ok(fragments)
64            })?;
65
66        if !self.changelogs_only {
67            for package in check_run.packages() {
68                for dep in &package.dependencies {
69                    if dep.path.is_none() || !matches!(dep.kind, DependencyKind::Build | DependencyKind::Normal) {
70                        continue;
71                    }
72
73                    let Some(pkg) = check_run.get_package(&dep.name) else {
74                        continue;
75                    };
76
77                    let depends_on = dep.req == pkg.unreleased_req();
78                    if depends_on && !check_run.is_accepted_group(pkg.group()) {
79                        anyhow::bail!(
80                            "could not update: `{}` because it depends on `{}` which is not part of the packages to be updated.",
81                            package.name,
82                            pkg.name
83                        );
84                    }
85                }
86            }
87
88            check_run.process(self.concurrency, &metadata.workspace_root, None)?;
89
90            for fragment in change_fragments.values() {
91                for (package, logs) in fragment.items().context("fragment items")? {
92                    let Some(pkg) = check_run.get_package(&package) else {
93                        tracing::warn!("unknown package: {package}");
94                        continue;
95                    };
96
97                    pkg.report_change();
98                    if logs.iter().any(|l| l.breaking) {
99                        pkg.report_breaking_change();
100                    }
101                }
102            }
103
104            let dependants = check_run
105                .all_packages()
106                .fold(HashMap::<_, Vec<_>>::new(), |mut deps, package| {
107                    package.dependencies.iter().for_each(|dep| {
108                        if dep.path.is_some() && check_run.get_package(&dep.name).is_some() {
109                            deps.entry(dep.name.as_str()).or_default().push((package, dep));
110                        }
111                    });
112                    deps
113                });
114
115            let mut found = false;
116            for iter in 0..10 {
117                let mut has_changes = false;
118                for group in check_run.groups() {
119                    let max_bump_version = group
120                        .iter()
121                        .map(|p| {
122                            p.version_bump()
123                                .map(|v| v.next_semver(p.version.clone()))
124                                .unwrap_or_else(|| p.version.clone())
125                        })
126                        .max()
127                        .unwrap();
128
129                    group
130                        .iter()
131                        .filter(|package| package.version != max_bump_version)
132                        .flat_map(|package| {
133                            package.set_next_version(max_bump_version.clone());
134                            dependants
135                                .get(package.name.as_ref())
136                                .into_iter()
137                                .flatten()
138                                .filter(|(_, dep)| {
139                                    !dep.req.matches(&max_bump_version) || dep.req == package.unreleased_req()
140                                })
141                                .map(move |(pkg, dep)| (package, pkg, dep))
142                        })
143                        .for_each(|(package, dep_pkg, dep)| {
144                            match dep.kind {
145                                // build deps always just get a simple version bump
146                                // since these deps can never be public
147                                DependencyKind::Build => {
148                                    dep_pkg.report_change();
149                                }
150                                // normal deps are way trickier because the change may be breaking
151                                DependencyKind::Normal => {
152                                    // This would have been the previous version req, that matched...
153                                    let typical_semver_req = semver::VersionReq {
154                                        comparators: vec![semver::Comparator {
155                                            op: semver::Op::Caret,
156                                            major: package.version.major,
157                                            minor: Some(package.version.minor),
158                                            patch: Some(package.version.patch),
159                                            pre: package.version.pre.clone(),
160                                        }],
161                                    };
162                                    if dep_pkg.public_deps().contains(&dep.name)
163                                        && !typical_semver_req.matches(&max_bump_version)
164                                        && dep_pkg.group() != package.group()
165                                    {
166                                        dep_pkg.report_breaking_change();
167                                    } else {
168                                        dep_pkg.report_change();
169                                    }
170                                }
171                                _ => {}
172                            }
173                        });
174
175                    group.iter().for_each(|p| {
176                        if p.version != max_bump_version && p.next_version().is_none_or(|v| v != max_bump_version) {
177                            tracing::debug!("{} to {} -> {max_bump_version}", p.name, p.version);
178                            p.set_next_version(max_bump_version.clone());
179                            has_changes = true;
180                        }
181                    });
182                }
183
184                if !has_changes {
185                    tracing::debug!("satisfied version constraints after {} iterations", iter + 1);
186                    found = true;
187                    break;
188                }
189            }
190
191            if !found {
192                anyhow::bail!("could not satisfy version constraints after 10 attempts");
193            }
194        }
195
196        let mut pr_body = String::from("## 🤖 New release\n\n");
197        let mut release_count = 0;
198
199        for package in check_run.packages() {
200            let _span = tracing::info_span!("update", package = %package.name).entered();
201            let version = package.next_version();
202            if !self.changelogs_only && version.is_none() {
203                continue;
204            }
205
206            release_count += 1;
207
208            if let Some(change_log_path_md) = package.changelog_path() {
209                let change_logs = generate_change_logs(&package.name, &mut change_fragments).context("generate")?;
210                if !change_logs.is_empty() {
211                    update_change_log(
212                        &change_logs,
213                        &change_log_path_md,
214                        &package.name,
215                        version.as_ref(),
216                        package.last_published_version().map(|v| v.vers).as_ref(),
217                        self.dry_run,
218                    )
219                    .context("update")?;
220                    if !self.dry_run {
221                        save_change_fragments(&mut change_fragments).context("save")?;
222                    }
223                    tracing::info!(package = %package.name, "updated change logs");
224                }
225            }
226
227            if !self.changelogs_only {
228                let version = version.unwrap();
229                pr_body.push_str(&format!("* `{}` -> {version}\n", package.name));
230                let cargo_toml_raw = std::fs::read_to_string(&package.manifest_path).context("read cargo toml")?;
231                let mut cargo_toml_edit = cargo_toml_raw.parse::<toml_edit::DocumentMut>().context("parse toml")?;
232                cargo_toml_edit["package"]["version"] = version.to_string().into();
233                for dep in &package.dependencies {
234                    if dep.path.is_none() {
235                        continue;
236                    }
237
238                    let kind = match dep.kind {
239                        DependencyKind::Build => "build-dependencies",
240                        DependencyKind::Normal => "dependencies",
241                        _ => continue,
242                    };
243
244                    let Some(pkg) = check_run.get_package(&dep.name) else {
245                        continue;
246                    };
247
248                    let depends_on = dep.req == pkg.unreleased_req();
249                    if !depends_on && pkg.next_version().is_none_or(|vers| dep.req.matches(&vers)) {
250                        continue;
251                    }
252
253                    let root = if let Some(target) = &dep.target {
254                        &mut cargo_toml_edit["target"][&target.to_string()]
255                    } else {
256                        cargo_toml_edit.as_item_mut()
257                    };
258
259                    let item = root[kind][&dep.name].as_table_like_mut().unwrap();
260                    let pkg_version = pkg.next_version().unwrap_or_else(|| pkg.version.clone());
261
262                    let version = if pkg.group() == package.group() {
263                        semver::VersionReq {
264                            comparators: vec![semver::Comparator {
265                                op: semver::Op::Exact,
266                                major: pkg_version.major,
267                                minor: Some(pkg_version.minor),
268                                patch: Some(pkg_version.patch),
269                                pre: pkg_version.pre.clone(),
270                            }],
271                        }
272                        .to_string()
273                    } else if depends_on {
274                        pkg_version.to_string()
275                    } else {
276                        let dep_versions = pkg.published_versions();
277                        let min_version = dep_versions
278                            .iter()
279                            .find(|v| dep.req.matches(&v.vers))
280                            .map(|v| &v.vers)
281                            .unwrap();
282
283                        let next_major = VersionBump::Major.next_semver(pkg_version);
284
285                        semver::VersionReq {
286                            comparators: vec![
287                                semver::Comparator {
288                                    op: semver::Op::GreaterEq,
289                                    major: min_version.major,
290                                    minor: Some(min_version.minor),
291                                    patch: Some(min_version.patch),
292                                    pre: min_version.pre.clone(),
293                                },
294                                semver::Comparator {
295                                    op: semver::Op::Less,
296                                    major: next_major.major,
297                                    minor: Some(next_major.minor),
298                                    patch: Some(next_major.patch),
299                                    pre: next_major.pre,
300                                },
301                            ],
302                        }
303                        .to_string()
304                    };
305
306                    item.insert("version", version.into());
307                }
308
309                let cargo_toml = cargo_toml_edit.to_string();
310                if cargo_toml != cargo_toml_raw {
311                    if !self.dry_run {
312                        std::fs::write(&package.manifest_path, cargo_toml).context("write cargo toml")?;
313                    } else {
314                        tracing::warn!("not modifying {} because dry-run", package.manifest_path);
315                    }
316                }
317            }
318        }
319
320        if release_count != 0 {
321            println!("{}", pr_body.trim());
322        } else {
323            tracing::info!("no packages to release!");
324        }
325
326        Ok(())
327    }
328}
329
330fn update_change_log(
331    logs: &[PackageChangeLog],
332    change_log_path_md: &Utf8Path,
333    name: &str,
334    version: Option<&Version>,
335    previous_version: Option<&Version>,
336    dry_run: bool,
337) -> anyhow::Result<()> {
338    let mut change_log = std::fs::read_to_string(change_log_path_md).context("failed to read CHANGELOG.md")?;
339
340    // Find the # [Unreleased] section
341    // So we can insert the new logs after it
342    let (mut breaking_changes, mut other_changes) = logs.iter().partition::<Vec<_>, _>(|log| log.breaking);
343    breaking_changes.sort_by_key(|log| &log.category);
344    other_changes.sort_by_key(|log| &log.category);
345
346    fn make_logs(logs: &[&PackageChangeLog]) -> String {
347        fmtools::fmt(|f| {
348            let mut first = true;
349            for log in logs {
350                if !first {
351                    f.write_char('\n')?;
352                }
353                first = false;
354
355                let (tag, desc) = log.description.split_once('\n').unwrap_or((&log.description, ""));
356                write!(f, "- {category}: {tag}", category = log.category, tag = tag.trim(),)?;
357
358                if !log.pr_numbers.is_empty() {
359                    f.write_str(" (")?;
360                    let mut first = true;
361                    for pr_number in &log.pr_numbers {
362                        if !first {
363                            f.write_str(", ")?;
364                        }
365                        first = false;
366                        write!(f, "[#{pr_number}](https://github.com/scufflecloud/scuffle/pull/{pr_number})")?;
367                    }
368                    f.write_str(")")?;
369                }
370
371                if !log.authors.is_empty() {
372                    f.write_str(" (")?;
373                    let mut first = true;
374                    let mut seen = HashSet::new();
375                    for author in &log.authors {
376                        let author = author.trim().trim_start_matches('@').trim();
377                        if !seen.insert(author.to_lowercase()) {
378                            continue;
379                        }
380
381                        if !first {
382                            f.write_str(", ")?;
383                        }
384                        first = false;
385                        f.write_char('@')?;
386                        f.write_str(author)?;
387                    }
388                    f.write_char(')')?;
389                }
390
391                let desc = desc.trim();
392
393                if !desc.is_empty() {
394                    f.write_str("\n\n")?;
395                    f.write_str(desc)?;
396                    f.write_char('\n')?;
397                }
398            }
399
400            Ok(())
401        })
402        .to_string()
403    }
404
405    let breaking_changes = make_logs(&breaking_changes);
406    let other_changes = make_logs(&other_changes);
407
408    let mut replaced = String::new();
409
410    replaced.push_str("## [Unreleased]\n");
411
412    if let Some(version) = version {
413        replaced.push_str(&format!(
414            "\n## [{version}](https://github.com/ScuffleCloud/scuffle/releases/tag/{name}-v{version}) - {date}\n\n",
415            date = chrono::Utc::now().date_naive().format("%Y-%m-%d")
416        ));
417
418        if let Some(previous_version) = &previous_version {
419            replaced.push_str(&format!(
420                "[View diff on diff.rs](https://diff.rs/{name}/{previous_version}/{name}/{version}/Cargo.toml)\n",
421            ));
422        }
423    }
424
425    if !breaking_changes.is_empty() {
426        replaced.push_str("\n### ⚠️ Breaking changes\n\n");
427        replaced.push_str(&breaking_changes);
428        replaced.push('\n');
429    }
430
431    if !other_changes.is_empty() {
432        replaced.push_str("\n### 🛠️ Non-breaking changes\n\n");
433        replaced.push_str(&other_changes);
434        replaced.push('\n');
435    }
436
437    change_log = change_log.replace("## [Unreleased]", replaced.trim());
438
439    if !dry_run {
440        std::fs::write(change_log_path_md, change_log).context("failed to write CHANGELOG.md")?;
441    } else {
442        tracing::warn!("not modifying {change_log_path_md} because dry-run");
443    }
444
445    Ok(())
446}
447
448fn generate_change_logs(
449    package: &str,
450    change_fragments: &mut BTreeMap<u64, Fragment>,
451) -> anyhow::Result<Vec<PackageChangeLog>> {
452    let mut logs = Vec::new();
453    let mut seen_logs = HashMap::new();
454
455    for fragment in change_fragments.values_mut() {
456        for log in fragment.remove_package(package).context("parse")? {
457            let key = (log.category.clone(), log.description.clone());
458            match seen_logs.entry(key) {
459                std::collections::hash_map::Entry::Vacant(v) => {
460                    v.insert(logs.len());
461                    logs.push(log);
462                }
463                std::collections::hash_map::Entry::Occupied(o) => {
464                    let old_log = &mut logs[*o.get()];
465                    old_log.pr_numbers.extend(log.pr_numbers);
466                    old_log.authors.extend(log.authors);
467                    old_log.breaking |= log.breaking;
468                }
469            }
470        }
471    }
472
473    Ok(logs)
474}
475
476fn save_change_fragments(fragments: &mut BTreeMap<u64, Fragment>) -> anyhow::Result<()> {
477    fragments
478        .values_mut()
479        .filter(|fragment| fragment.changed())
480        .try_for_each(|fragment| fragment.save().context("save"))?;
481
482    fragments.retain(|_, fragment| !fragment.deleted());
483
484    Ok(())
485}
486
487#[derive(Debug, Clone)]
488pub struct Fragment {
489    path: Utf8PathBuf,
490    pr_number: u64,
491    toml: toml_edit::DocumentMut,
492    changed: bool,
493    deleted: bool,
494}
495
496#[derive(Debug, Clone, Deserialize, Serialize)]
497#[serde(deny_unknown_fields)]
498pub struct PackageChangeLog {
499    #[serde(skip, default)]
500    pub pr_numbers: BTreeSet<u64>,
501    #[serde(alias = "cat")]
502    pub category: String,
503    #[serde(alias = "desc")]
504    pub description: String,
505    #[serde(default, skip_serializing_if = "Vec::is_empty")]
506    #[serde(alias = "author")]
507    pub authors: Vec<String>,
508    #[serde(default, skip_serializing_if = "is_false")]
509    #[serde(alias = "break", alias = "major")]
510    pub breaking: bool,
511}
512
513fn is_false(input: &bool) -> bool {
514    !*input
515}
516
517impl PackageChangeLog {
518    pub fn new(category: impl std::fmt::Display, desc: impl std::fmt::Display) -> Self {
519        Self {
520            pr_numbers: BTreeSet::new(),
521            authors: Vec::new(),
522            breaking: false,
523            category: category.to_string(),
524            description: desc.to_string(),
525        }
526    }
527}
528
529impl Fragment {
530    pub fn new(pr_number: u64, root: &Utf8Path) -> anyhow::Result<Self> {
531        let path = root.join("changes.d").join(format!("pr-{pr_number}.toml"));
532        if path.exists() {
533            let content = std::fs::read_to_string(&path).context("read")?;
534            Ok(Fragment {
535                pr_number,
536                path: path.to_path_buf(),
537                toml: content
538                    .parse::<toml_edit::DocumentMut>()
539                    .context("change log is not valid toml")?,
540                changed: false,
541                deleted: false,
542            })
543        } else {
544            Ok(Fragment {
545                changed: false,
546                deleted: true,
547                path: path.to_path_buf(),
548                pr_number,
549                toml: DocumentMut::new(),
550            })
551        }
552    }
553
554    pub fn changed(&self) -> bool {
555        self.changed
556    }
557
558    pub fn deleted(&self) -> bool {
559        self.deleted
560    }
561
562    pub fn path(&self) -> &Utf8Path {
563        &self.path
564    }
565
566    pub fn has_package(&self, package: &str) -> bool {
567        self.toml.contains_key(package)
568    }
569
570    pub fn items(&self) -> anyhow::Result<BTreeMap<String, Vec<PackageChangeLog>>> {
571        self.toml
572            .iter()
573            .map(|(package, item)| package_to_logs(self.pr_number, item.clone()).map(|logs| (package.to_owned(), logs)))
574            .collect()
575    }
576
577    pub fn add_log(&mut self, package: &str, log: &PackageChangeLog) {
578        if !self.toml.contains_key(package) {
579            self.toml.insert(package, toml_edit::Item::ArrayOfTables(Default::default()));
580        }
581
582        self.changed = true;
583
584        self.toml[package]
585            .as_array_of_tables_mut()
586            .unwrap()
587            .push(toml_edit::ser::to_document(log).expect("invalid log").as_table().clone())
588    }
589
590    pub fn remove_package(&mut self, package: &str) -> anyhow::Result<Vec<PackageChangeLog>> {
591        let Some(items) = self.toml.remove(package) else {
592            return Ok(Vec::new());
593        };
594
595        self.changed = true;
596
597        package_to_logs(self.pr_number, items)
598    }
599
600    pub fn save(&mut self) -> anyhow::Result<()> {
601        if !self.changed {
602            return Ok(());
603        }
604
605        if self.toml.is_empty() {
606            if !self.deleted {
607                tracing::debug!(path = %self.path, "removing change fragment cause empty");
608                std::fs::remove_file(&self.path).context("remove")?;
609                self.deleted = true;
610            }
611        } else {
612            tracing::debug!(path = %self.path, "saving change fragment");
613            std::fs::write(&self.path, self.toml.to_string()).context("write")?;
614            self.deleted = false;
615        }
616
617        self.changed = false;
618
619        Ok(())
620    }
621}
622
623fn package_to_logs(pr_number: u64, items: toml_edit::Item) -> anyhow::Result<Vec<PackageChangeLog>> {
624    let value = items.into_value().expect("items must be a value").into_deserializer();
625    let mut logs = Vec::<PackageChangeLog>::deserialize(value).context("deserialize")?;
626
627    logs.iter_mut().for_each(|log| {
628        log.category = log.category.to_lowercase();
629        log.pr_numbers = BTreeSet::from_iter([pr_number]);
630    });
631
632    Ok(logs)
633}