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(¤t)
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;