Skip to main content

xtask/
release_versions.rs

1use anyhow::{bail, Context, Result};
2use semver::Version;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::path::Path;
6use std::process::Command;
7
8#[path = "release_versions_identifiers.rs"]
9mod identifiers;
10#[path = "release_versions_manifest.rs"]
11mod manifest;
12
13use identifiers::{
14    read_npm_identifier_version, read_oci_identifier_version, replace_npm_identifier_version,
15    replace_oci_identifier_version,
16};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum GateMode {
20    Pr,
21    Main,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum BumpLevel {
26    Patch,
27    Minor,
28    Major,
29}
30
31#[derive(Debug, Clone, Serialize)]
32pub struct ComponentPlan {
33    pub id: String,
34    pub name: String,
35    pub changed: bool,
36    pub version: String,
37    pub candidate_tag: String,
38    pub last_tag: Option<String>,
39    pub release_workflow: String,
40    pub shipping_paths: Vec<String>,
41}
42
43#[derive(Debug, Deserialize)]
44struct Manifest {
45    schema_version: u32,
46    components: Vec<Component>,
47}
48
49#[derive(Debug, Deserialize)]
50struct Component {
51    id: String,
52    name: String,
53    tag_prefix: String,
54    release_workflow: String,
55    shipping_paths: Vec<String>,
56    version_source: VersionFile,
57    version_files: Vec<VersionFile>,
58}
59
60#[derive(Debug, Deserialize, Clone)]
61struct VersionFile {
62    kind: VersionKind,
63    path: String,
64    package: Option<String>,
65    json_pointer: Option<String>,
66}
67
68#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
69#[serde(rename_all = "snake_case")]
70enum VersionKind {
71    CargoPackage,
72    CargoLockPackage,
73    ChangelogHeading,
74    JsonVersion,
75    JsonNoVersion,
76    OciIdentifierVersion,
77    NpmIdentifierVersion,
78}
79
80pub fn check(
81    root: &Path,
82    base: Option<&str>,
83    head: &str,
84    mode: GateMode,
85    json: bool,
86) -> Result<()> {
87    let manifest = load_manifest(root)?;
88    let plans = build_plan(root, &manifest, base, head, mode)?;
89    let mut errors = Vec::new();
90
91    for (component, plan) in manifest.components.iter().zip(plans.iter()) {
92        errors.extend(
93            check_component_parity(root, component, &plan.version)?
94                .into_iter()
95                .map(|error| format!("{}: {error}", component.id)),
96        );
97
98        if !plan.changed {
99            continue;
100        }
101
102        let candidate = Version::parse(&plan.version).with_context(|| {
103            format!(
104                "{} version is not valid semver: {}",
105                component.id, plan.version
106            )
107        })?;
108        if let Some(latest) = latest_version_from_plan(component, plan)? {
109            if candidate <= latest {
110                errors.push(format!(
111                    "{} changed but version {} is not greater than latest {} tag version {}",
112                    component.id, plan.version, component.tag_prefix, latest
113                ));
114            }
115        }
116        if tag_exists(root, &plan.candidate_tag)? {
117            errors.push(format!(
118                "{} changed but tag {} already exists",
119                component.id, plan.candidate_tag
120            ));
121        }
122    }
123
124    print_plans(&plans, json)?;
125
126    if !errors.is_empty() {
127        for error in &errors {
128            eprintln!("release version error: {error}");
129        }
130        bail!(
131            "release version check failed ({} error(s)): {}",
132            errors.len(),
133            errors.join("; ")
134        );
135    }
136
137    Ok(())
138}
139
140pub fn plan(
141    root: &Path,
142    base: Option<&str>,
143    head: &str,
144    mode: GateMode,
145) -> Result<Vec<ComponentPlan>> {
146    let manifest = load_manifest(root)?;
147    build_plan(root, &manifest, base, head, mode)
148}
149
150pub fn print_plans(plans: &[ComponentPlan], json: bool) -> Result<()> {
151    if json {
152        println!("{}", serde_json::to_string_pretty(plans)?);
153    } else {
154        for plan in plans {
155            println!(
156                "{} changed={} version={} tag={} last_tag={} workflow={}",
157                plan.id,
158                plan.changed,
159                plan.version,
160                plan.candidate_tag,
161                plan.last_tag.as_deref().unwrap_or("-"),
162                plan.release_workflow
163            );
164        }
165    }
166    Ok(())
167}
168
169pub fn bump(root: &Path, component_id: &str, level: BumpLevel) -> Result<()> {
170    let manifest = load_manifest(root)?;
171    let component = manifest
172        .components
173        .iter()
174        .find(|component| component.id == component_id)
175        .with_context(|| format!("unknown release component {component_id}"))?;
176    let current = read_version(root, &component.version_source)?;
177    let current = Version::parse(&current)
178        .with_context(|| format!("{} version is not valid semver: {current}", component.id))?;
179    let next = match level {
180        BumpLevel::Patch => Version::new(current.major, current.minor, current.patch + 1),
181        BumpLevel::Minor => Version::new(current.major, current.minor + 1, 0),
182        BumpLevel::Major => Version::new(current.major + 1, 0, 0),
183    }
184    .to_string();
185
186    set_component_version(root, component, &next)
187}
188
189pub fn sync_release_please_version(root: &Path, component_id: &str) -> Result<()> {
190    let manifest = load_manifest(root)?;
191    let component = manifest
192        .components
193        .iter()
194        .find(|component| component.id == component_id)
195        .with_context(|| format!("unknown release component {component_id}"))?;
196    let content = std::fs::read_to_string(root.join(".release-please-manifest.json"))
197        .context("failed to read .release-please-manifest.json")?;
198    let value: serde_json::Value =
199        serde_json::from_str(&content).context("failed to parse .release-please-manifest.json")?;
200    let version = value
201        .get(".")
202        .and_then(serde_json::Value::as_str)
203        .context("missing root package version in .release-please-manifest.json")?;
204    Version::parse(version)
205        .with_context(|| format!("release-please version is not valid semver: {version}"))?;
206    set_component_version(root, component, version)
207}
208
209fn set_component_version(root: &Path, component: &Component, next: &str) -> Result<()> {
210    for file in &component.version_files {
211        let path = root.join(&file.path);
212        let content = std::fs::read_to_string(&path)
213            .with_context(|| format!("failed to read {}", file.path))?;
214        let updated = match file.kind {
215            VersionKind::CargoPackage => {
216                replace_cargo_package_version(&content, file.package.as_deref(), next)?
217            }
218            VersionKind::CargoLockPackage => {
219                replace_cargo_lock_package_version(&content, file.package.as_deref(), next)?
220            }
221            VersionKind::ChangelogHeading => ensure_changelog_heading(&content, next),
222            VersionKind::JsonVersion => {
223                replace_json_version(&content, file.json_pointer.as_deref(), next)?
224            }
225            VersionKind::OciIdentifierVersion => {
226                replace_oci_identifier_version(&content, file.json_pointer.as_deref(), next)?
227            }
228            VersionKind::NpmIdentifierVersion => {
229                replace_npm_identifier_version(&content, file.json_pointer.as_deref(), next)?
230            }
231            VersionKind::JsonNoVersion => content.clone(),
232        };
233        if updated != content {
234            std::fs::write(&path, updated)
235                .with_context(|| format!("failed to write {}", file.path))?;
236        }
237    }
238
239    Ok(())
240}
241
242pub fn check_version_sync(root: &Path) -> Result<()> {
243    let manifest = load_manifest(root)?;
244    for component in &manifest.components {
245        let version = read_version(root, &component.version_source)?;
246        Version::parse(&version)
247            .with_context(|| format!("{} version is not valid semver: {version}", component.id))?;
248        let errors = check_component_parity(root, component, &version)?;
249        if !errors.is_empty() {
250            for error in &errors {
251                eprintln!("version sync error: {}: {error}", component.id);
252            }
253            bail!("version sync check failed ({} error(s))", errors.len());
254        }
255        println!(
256            "OK: {} version-bearing files are in sync at {version}.",
257            component.id
258        );
259    }
260    Ok(())
261}
262
263fn load_manifest(root: &Path) -> Result<Manifest> {
264    let content = std::fs::read_to_string(root.join("release/components.toml"))
265        .context("failed to read release/components.toml")?;
266    let manifest: Manifest =
267        toml::from_str(&content).context("failed to parse release/components.toml")?;
268    if manifest.schema_version != 1 {
269        bail!(
270            "unsupported release/components.toml schema_version {}",
271            manifest.schema_version
272        );
273    }
274    manifest::validate_manifest(&manifest)?;
275    Ok(manifest)
276}
277
278fn build_plan(
279    root: &Path,
280    manifest: &Manifest,
281    base: Option<&str>,
282    head: &str,
283    mode: GateMode,
284) -> Result<Vec<ComponentPlan>> {
285    manifest
286        .components
287        .iter()
288        .map(|component| {
289            let version = read_version(root, &component.version_source)?;
290            Version::parse(&version).with_context(|| {
291                format!("{} version is not valid semver: {version}", component.id)
292            })?;
293            let candidate_tag = format!("{}{}", component.tag_prefix, version);
294            let last_tag = latest_tag(root, &component.tag_prefix)?;
295            let changed = match mode {
296                GateMode::Pr => {
297                    let base = base.unwrap_or("origin/main");
298                    let compare_ref = merge_base(root, base, head)?;
299                    component_changed_since_ref(root, component, &compare_ref, head)?
300                }
301                GateMode::Main => match last_tag.as_deref() {
302                    Some(tag) => component_changed_since_ref(root, component, tag, head)?,
303                    None => true,
304                },
305            };
306            Ok(ComponentPlan {
307                id: component.id.clone(),
308                name: component.name.clone(),
309                changed,
310                version,
311                candidate_tag,
312                last_tag,
313                release_workflow: component.release_workflow.clone(),
314                shipping_paths: component.shipping_paths.clone(),
315            })
316        })
317        .collect()
318}
319
320fn read_version(root: &Path, file: &VersionFile) -> Result<String> {
321    let path = root.join(&file.path);
322    let content =
323        std::fs::read_to_string(&path).with_context(|| format!("failed to read {}", file.path))?;
324    match file.kind {
325        VersionKind::CargoPackage => read_cargo_package_version(&content, file.package.as_deref()),
326        VersionKind::CargoLockPackage => {
327            read_cargo_lock_package_version(&content, file.package.as_deref())
328        }
329        VersionKind::JsonVersion => read_json_version(&content, file.json_pointer.as_deref()),
330        VersionKind::OciIdentifierVersion => {
331            read_oci_identifier_version(&content, file.json_pointer.as_deref())
332        }
333        VersionKind::NpmIdentifierVersion => {
334            read_npm_identifier_version(&content, file.json_pointer.as_deref())
335        }
336        VersionKind::ChangelogHeading | VersionKind::JsonNoVersion => {
337            bail!("{:?} is not a canonical version source", file.kind)
338        }
339    }
340    .with_context(|| format!("failed to read {:?} from {}", file.kind, file.path))
341}
342
343fn check_component_parity(
344    root: &Path,
345    component: &Component,
346    expected: &str,
347) -> Result<Vec<String>> {
348    let mut errors = Vec::new();
349    for file in &component.version_files {
350        let content = match std::fs::read_to_string(root.join(&file.path)) {
351            Ok(content) => content,
352            Err(error) => {
353                errors.push(format!("{}: failed to read: {error}", file.path));
354                continue;
355            }
356        };
357        let result = match file.kind {
358            VersionKind::CargoPackage => check_version(
359                read_cargo_package_version(&content, file.package.as_deref()),
360                expected,
361            ),
362            VersionKind::CargoLockPackage => check_version(
363                read_cargo_lock_package_version(&content, file.package.as_deref()),
364                expected,
365            ),
366            VersionKind::ChangelogHeading => check_changelog_heading(&content, expected),
367            VersionKind::JsonVersion => check_version(
368                read_json_version(&content, file.json_pointer.as_deref()),
369                expected,
370            ),
371            VersionKind::OciIdentifierVersion => check_version(
372                read_oci_identifier_version(&content, file.json_pointer.as_deref()),
373                expected,
374            ),
375            VersionKind::NpmIdentifierVersion => check_version(
376                read_npm_identifier_version(&content, file.json_pointer.as_deref()),
377                expected,
378            ),
379            VersionKind::JsonNoVersion => check_json_no_version(&content),
380        };
381        if let Err(error) = result {
382            errors.push(format!("{}: {error}", file.path));
383        }
384    }
385    Ok(errors)
386}
387
388fn check_version(actual: Result<String>, expected: &str) -> Result<()> {
389    let actual = actual?;
390    if actual != expected {
391        bail!("expected version {expected}, found {actual}");
392    }
393    Ok(())
394}
395
396fn read_cargo_package_version(content: &str, package: Option<&str>) -> Result<String> {
397    let value: toml::Value = toml::from_str(content).context("invalid TOML")?;
398    let table = value
399        .get("package")
400        .and_then(|value| value.as_table())
401        .context("missing [package] table")?;
402    if let Some(expected_name) = package {
403        let name = table
404            .get("name")
405            .and_then(|value| value.as_str())
406            .context("missing package.name")?;
407        if name != expected_name {
408            bail!("expected package {expected_name}, found {name}");
409        }
410    }
411    table
412        .get("version")
413        .and_then(|value| value.as_str())
414        .map(ToOwned::to_owned)
415        .context("missing package.version")
416}
417
418fn read_cargo_lock_package_version(content: &str, package: Option<&str>) -> Result<String> {
419    let package = package.context("cargo_lock_package requires package")?;
420    let sections = cargo_lock_package_sections(content);
421    sections
422        .get(package)
423        .map(ToOwned::to_owned)
424        .with_context(|| format!("missing Cargo.lock package {package}"))
425}
426
427fn cargo_lock_package_sections(content: &str) -> BTreeMap<String, String> {
428    let mut packages = BTreeMap::new();
429    for section in content.split("[[package]]").skip(1) {
430        let Some(name) = cargo_lock_field(section, "name") else {
431            continue;
432        };
433        let Some(version) = cargo_lock_field(section, "version") else {
434            continue;
435        };
436        packages.insert(name, version);
437    }
438    packages
439}
440
441fn cargo_lock_field(section: &str, key: &str) -> Option<String> {
442    let prefix = format!("{key} = ");
443    section.lines().find_map(|line| {
444        line.trim()
445            .strip_prefix(&prefix)
446            .and_then(|value| value.trim().strip_prefix('"')?.strip_suffix('"'))
447            .map(ToOwned::to_owned)
448    })
449}
450
451fn read_json_version(content: &str, pointer: Option<&str>) -> Result<String> {
452    let value = parse_json_value(content)?;
453    let pointer = pointer.unwrap_or("/version");
454    value
455        .pointer(pointer)
456        .and_then(|value| value.as_str())
457        .map(ToOwned::to_owned)
458        .with_context(|| format!("missing JSON string at {pointer}"))
459}
460
461fn check_changelog_heading(content: &str, expected: &str) -> Result<()> {
462    let expected = format!("## [{expected}]");
463    if !content.lines().any(|line| line.starts_with(&expected)) {
464        bail!("missing '{expected}' heading");
465    }
466    Ok(())
467}
468
469fn check_json_no_version(content: &str) -> Result<()> {
470    let value = parse_json_value(content)?;
471    if contains_json_version_key(&value) {
472        bail!("must not contain a version key");
473    }
474    Ok(())
475}
476
477fn contains_json_version_key(value: &serde_json::Value) -> bool {
478    match value {
479        serde_json::Value::Object(map) => {
480            map.contains_key("version") || map.values().any(contains_json_version_key)
481        }
482        serde_json::Value::Array(values) => values.iter().any(contains_json_version_key),
483        _ => false,
484    }
485}
486
487fn replace_cargo_package_version(
488    content: &str,
489    package: Option<&str>,
490    next: &str,
491) -> Result<String> {
492    read_cargo_package_version(content, package)?;
493    replace_package_table_version(content, next)
494}
495
496fn replace_cargo_lock_package_version(
497    content: &str,
498    package: Option<&str>,
499    next: &str,
500) -> Result<String> {
501    let package = package.context("cargo_lock_package requires package")?;
502    let mut in_target = false;
503    let mut replaced = false;
504    let mut saw_name = false;
505    let mut output = Vec::new();
506    for line in content.lines() {
507        let trimmed = line.trim();
508        if trimmed == "[[package]]" {
509            in_target = false;
510            saw_name = false;
511        } else if let Some(name) = cargo_lock_field(line, "name") {
512            saw_name = name == package;
513            in_target = saw_name;
514        }
515
516        let mut next_line = line.to_owned();
517        if in_target && saw_name && trimmed.starts_with("version = ") {
518            let leading = &line[..line.len() - line.trim_start().len()];
519            next_line = format!(r#"{leading}version = "{next}""#);
520            replaced = true;
521            in_target = false;
522        }
523        output.push(next_line);
524    }
525    if !replaced {
526        bail!("missing Cargo.lock package {package} version");
527    }
528    Ok(preserve_trailing_newline(content, output.join("\n")))
529}
530
531fn replace_package_table_version(content: &str, next: &str) -> Result<String> {
532    let mut in_package = false;
533    let mut replaced = false;
534    let mut output = Vec::new();
535    for line in content.lines() {
536        let trimmed = line.trim();
537        if trimmed == "[package]" {
538            in_package = true;
539        } else if in_package && trimmed.starts_with('[') {
540            in_package = false;
541        }
542
543        let mut next_line = line.to_owned();
544        if in_package && trimmed.starts_with("version = ") {
545            let leading = &line[..line.len() - line.trim_start().len()];
546            next_line = format!(r#"{leading}version = "{next}""#);
547            replaced = true;
548        }
549        output.push(next_line);
550    }
551    if !replaced {
552        bail!("missing Cargo package version");
553    }
554    Ok(preserve_trailing_newline(content, output.join("\n")))
555}
556
557fn replace_json_version(content: &str, pointer: Option<&str>, next: &str) -> Result<String> {
558    let pointer = pointer.unwrap_or("/version");
559    let mut value = parse_json_value(content)?;
560    let target = value
561        .pointer_mut(pointer)
562        .with_context(|| format!("missing JSON version field at {pointer}"))?;
563    if !target.is_string() {
564        bail!("JSON version field at {pointer} is not a string");
565    }
566    *target = serde_json::Value::String(next.to_owned());
567    write_json_preserving_prefix(content, &value)
568}
569
570fn parse_json_value(content: &str) -> Result<serde_json::Value> {
571    let json = content
572        .get(json_start(content)?..)
573        .context("invalid UTF-8 boundary for JSON payload")?;
574    serde_json::from_str(json).context("invalid JSON")
575}
576
577fn json_start(content: &str) -> Result<usize> {
578    content.find('{').context("missing JSON object")
579}
580
581fn write_json_preserving_prefix(original: &str, value: &serde_json::Value) -> Result<String> {
582    let start = json_start(original)?;
583    let mut output = String::new();
584    output.push_str(&original[..start]);
585    output.push_str(&serde_json::to_string_pretty(value).context("failed to serialize JSON")?);
586    output.push('\n');
587    Ok(output)
588}
589
590fn ensure_changelog_heading(content: &str, next: &str) -> String {
591    let heading = format!("## [{next}]");
592    if content.lines().any(|line| line.starts_with(&heading)) {
593        return content.to_owned();
594    }
595    let mut output = String::new();
596    let mut inserted = false;
597    for line in content.lines() {
598        output.push_str(line);
599        output.push('\n');
600        if !inserted && line.trim() == "## [Unreleased]" {
601            output.push('\n');
602            output.push_str(&heading);
603            output.push_str("\n\n");
604            inserted = true;
605        }
606    }
607    if inserted {
608        output
609    } else {
610        format!("{content}\n\n{heading}\n")
611    }
612}
613
614fn preserve_trailing_newline(original: &str, output: String) -> String {
615    if original.ends_with('\n') && !output.ends_with('\n') {
616        format!("{output}\n")
617    } else {
618        output
619    }
620}
621
622fn latest_tag(root: &Path, prefix: &str) -> Result<Option<String>> {
623    let output = git_output(root, &["tag", "-l", &format!("{prefix}*")])?;
624    let mut candidates = Vec::new();
625    for tag in output.lines().filter(|line| !line.trim().is_empty()) {
626        let Some(version) = tag.strip_prefix(prefix) else {
627            continue;
628        };
629        if let Ok(version) = Version::parse(version) {
630            candidates.push((version, tag.to_owned()));
631        }
632    }
633    candidates.sort_by(|left, right| left.0.cmp(&right.0));
634    Ok(candidates.pop().map(|(_, tag)| tag))
635}
636
637fn latest_version_from_plan(
638    component: &Component,
639    plan: &ComponentPlan,
640) -> Result<Option<Version>> {
641    plan.last_tag
642        .as_deref()
643        .map(|tag| {
644            let version = tag
645                .strip_prefix(&component.tag_prefix)
646                .with_context(|| format!("{} latest tag has wrong prefix: {tag}", component.id))?;
647            Version::parse(version).with_context(|| {
648                format!(
649                    "{} latest tag has invalid semver suffix: {tag}",
650                    component.id
651                )
652            })
653        })
654        .transpose()
655}
656
657fn tag_exists(root: &Path, tag: &str) -> Result<bool> {
658    let output = Command::new("git")
659        .arg("-C")
660        .arg(root)
661        .args(["rev-parse", "-q", "--verify"])
662        .arg(format!("refs/tags/{tag}"))
663        .output()
664        .with_context(|| format!("failed to check tag {tag}"))?;
665    Ok(output.status.success())
666}
667
668fn component_changed_since_ref(
669    root: &Path,
670    component: &Component,
671    base: &str,
672    head: &str,
673) -> Result<bool> {
674    let output = Command::new("git")
675        .arg("-C")
676        .arg(root)
677        .args(["diff", "--name-only"])
678        .arg(format!("{base}..{head}"))
679        .arg("--")
680        .args(&component.shipping_paths)
681        .output()
682        .with_context(|| format!("failed to diff {base}..{head}"))?;
683    if !output.status.success() {
684        bail!(
685            "git diff failed for {base}..{head}: {}",
686            String::from_utf8_lossy(&output.stderr).trim()
687        );
688    }
689    Ok(String::from_utf8_lossy(&output.stdout)
690        .lines()
691        .any(|line| !line.trim().is_empty()))
692}
693
694fn merge_base(root: &Path, base: &str, head: &str) -> Result<String> {
695    git_output(root, &["merge-base", base, head]).map(|output| output.trim().to_owned())
696}
697
698fn git_output(root: &Path, args: &[&str]) -> Result<String> {
699    let output = Command::new("git")
700        .arg("-C")
701        .arg(root)
702        .args(args)
703        .output()
704        .with_context(|| format!("failed to run git {args:?}"))?;
705    if !output.status.success() {
706        bail!(
707            "git {:?} failed: {}",
708            args,
709            String::from_utf8_lossy(&output.stderr).trim()
710        );
711    }
712    Ok(String::from_utf8_lossy(&output.stdout).to_string())
713}
714
715#[cfg(test)]
716#[path = "release_versions_tests.rs"]
717mod tests;