Skip to main content

xtask/
release_versions.rs

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