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