1use std::fs::{self, OpenOptions};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use anyhow::{Context, Result, bail};
7use serde::{Deserialize, Serialize};
8
9use crate::team::config::TeamConfig;
10use crate::team::daemon::verification::run_automatic_verification;
11use crate::team::events::{EventSink, TeamEvent};
12
13const RELEASES_DIR: &str = ".batty/releases";
14const RELEASE_HISTORY_FILE: &str = "history.jsonl";
15const RELEASE_LATEST_JSON: &str = "latest.json";
16const RELEASE_LATEST_MARKDOWN: &str = "latest.md";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19struct ReleaseMetadata {
20 package_name: String,
21 version: String,
22 tag: String,
23 changelog_heading: String,
24 changelog_body: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28struct ReleaseContext {
29 metadata: ReleaseMetadata,
30 branch: String,
31 git_ref: String,
32 previous_tag: Option<String>,
33 commits_since_previous: usize,
34 commit_summaries: Vec<String>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38struct ReleaseVerification {
39 command: String,
40 passed: bool,
41 summary: String,
42 details: Option<String>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct ReleaseRecord {
47 pub ts: String,
48 pub package_name: Option<String>,
49 pub version: Option<String>,
50 pub tag: Option<String>,
51 pub git_ref: Option<String>,
52 pub branch: Option<String>,
53 pub previous_tag: Option<String>,
54 pub commits_since_previous: Option<usize>,
55 pub verification_command: Option<String>,
56 pub verification_summary: Option<String>,
57 pub success: bool,
58 pub reason: String,
59 pub details: Option<String>,
60 pub notes_path: Option<String>,
61}
62
63#[derive(Debug, Clone, Default)]
64struct ReleaseDraft {
65 package_name: Option<String>,
66 version: Option<String>,
67 tag: Option<String>,
68 git_ref: Option<String>,
69 branch: Option<String>,
70 previous_tag: Option<String>,
71 commits_since_previous: Option<usize>,
72 verification_command: Option<String>,
73 verification_summary: Option<String>,
74 notes_path: Option<String>,
75}
76
77#[derive(Debug, Clone)]
78struct ReleaseFailure {
79 record: ReleaseRecord,
80 report_markdown: String,
81 message: String,
82}
83
84trait VerificationRunner {
85 fn run(&self, project_root: &Path) -> Result<ReleaseVerification>;
86}
87
88struct ConfiguredVerificationRunner {
89 command_override: Option<String>,
90}
91
92impl VerificationRunner for ConfiguredVerificationRunner {
93 fn run(&self, project_root: &Path) -> Result<ReleaseVerification> {
94 let command = resolve_verification_command(project_root, self.command_override.as_deref())?;
95 let run = run_automatic_verification(project_root, Some(&command)).with_context(|| {
96 format!("failed while running release verification command `{command}`")
97 })?;
98 let summary = if run.passed {
99 run.results
100 .summary
101 .clone()
102 .unwrap_or_else(|| format!("{command} passed"))
103 } else if !run.failures.is_empty() {
104 run.failures.join("; ")
105 } else {
106 run.results.failure_summary()
107 };
108 let details = trimmed_output(&run.output);
109
110 Ok(ReleaseVerification {
111 command,
112 passed: run.passed,
113 summary,
114 details,
115 })
116 }
117}
118
119pub fn cmd_release(project_root: &Path, requested_tag: Option<&str>) -> Result<()> {
120 let verifier = ConfiguredVerificationRunner {
121 command_override: None,
122 };
123
124 match run_release_with_verifier(project_root, requested_tag, &verifier) {
125 Ok((record, report_markdown)) => {
126 persist_release_record(project_root, &record)?;
127 write_latest_report(project_root, &report_markdown)?;
128 emit_release_record(project_root, &record)?;
129 println!(
130 "Release succeeded: {} -> {}",
131 record.tag.as_deref().unwrap_or("unknown-tag"),
132 record.git_ref.as_deref().unwrap_or("unknown-ref")
133 );
134 if let Some(path) = record.notes_path.as_deref() {
135 println!("Release notes: {path}");
136 }
137 println!(
138 "Verification: {}",
139 record
140 .verification_summary
141 .as_deref()
142 .unwrap_or("no verification summary recorded")
143 );
144 Ok(())
145 }
146 Err(failure) => {
147 persist_release_record(project_root, &failure.record)?;
148 write_latest_report(project_root, &failure.report_markdown)?;
149 emit_release_record(project_root, &failure.record)?;
150 bail!("{}", failure.message);
151 }
152 }
153}
154
155pub fn latest_record(project_root: &Path) -> Result<Option<ReleaseRecord>> {
156 let path = releases_dir(project_root).join(RELEASE_LATEST_JSON);
157 if !path.exists() {
158 return Ok(None);
159 }
160 let content =
161 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
162 let record = serde_json::from_str(&content)
163 .with_context(|| format!("failed to parse {}", path.display()))?;
164 Ok(Some(record))
165}
166
167#[allow(clippy::result_large_err)]
168fn run_release_with_verifier(
169 project_root: &Path,
170 requested_tag: Option<&str>,
171 verifier: &dyn VerificationRunner,
172) -> std::result::Result<(ReleaseRecord, String), ReleaseFailure> {
173 let mut draft = ReleaseDraft::default();
174
175 let metadata = load_release_metadata(project_root, requested_tag).map_err(|error| {
176 failure(
177 &draft,
178 "missing_release_metadata",
179 "release metadata is missing or invalid",
180 Some(error.to_string()),
181 )
182 })?;
183 draft.package_name = Some(metadata.package_name.clone());
184 draft.version = Some(metadata.version.clone());
185 draft.tag = Some(metadata.tag.clone());
186
187 let branch =
188 git_stdout(project_root, &["rev-parse", "--abbrev-ref", "HEAD"]).map_err(|error| {
189 failure(
190 &draft,
191 "git_branch_lookup_failed",
192 "failed to resolve the current branch before releasing",
193 Some(error.to_string()),
194 )
195 })?;
196 draft.branch = Some(branch.clone());
197 if branch != "main" {
198 return Err(failure(
199 &draft,
200 "not_on_main",
201 "release requires the project root to be on `main`",
202 Some(format!("current branch is `{branch}`")),
203 ));
204 }
205
206 let dirty = git_stdout(project_root, &["status", "--porcelain"]).map_err(|error| {
207 failure(
208 &draft,
209 "git_status_failed",
210 "failed to inspect the `main` worktree before releasing",
211 Some(error.to_string()),
212 )
213 })?;
214 if dirty.lines().any(|line| !line.starts_with("?? .batty/")) {
215 return Err(failure(
216 &draft,
217 "dirty_main",
218 "release readiness failed: `main` has uncommitted changes",
219 Some("commit, stash, or remove local changes before releasing".to_string()),
220 ));
221 }
222
223 let git_ref = git_stdout(project_root, &["rev-parse", "main"]).map_err(|error| {
224 failure(
225 &draft,
226 "git_ref_lookup_failed",
227 "failed to resolve the `main` git ref before releasing",
228 Some(error.to_string()),
229 )
230 })?;
231 draft.git_ref = Some(git_ref.clone());
232
233 if git_ref_for_tag(project_root, &metadata.tag)
234 .map_err(|error| {
235 failure(
236 &draft,
237 "tag_lookup_failed",
238 "failed to inspect whether the target release tag already exists",
239 Some(error.to_string()),
240 )
241 })?
242 .is_some()
243 {
244 return Err(failure(
245 &draft,
246 "tag_exists",
247 "release readiness failed: the target tag already exists",
248 Some(format!(
249 "tag `{}` already points at an existing release",
250 metadata.tag
251 )),
252 ));
253 }
254
255 let previous_tag = latest_git_tag(project_root).map_err(|error| {
256 failure(
257 &draft,
258 "previous_tag_lookup_failed",
259 "failed to resolve the previous release tag",
260 Some(error.to_string()),
261 )
262 })?;
263 draft.previous_tag = previous_tag.clone();
264
265 let commits_since_previous = count_commits_since(project_root, previous_tag.as_deref())
266 .map_err(|error| {
267 failure(
268 &draft,
269 "commit_count_failed",
270 "failed to count commits included in this release",
271 Some(error.to_string()),
272 )
273 })?;
274 draft.commits_since_previous = Some(commits_since_previous);
275
276 let verification = verifier.run(project_root).map_err(|error| {
277 failure(
278 &draft,
279 "verification_start_failed",
280 "release verification could not start",
281 Some(error.to_string()),
282 )
283 })?;
284 draft.verification_command = Some(verification.command.clone());
285 draft.verification_summary = Some(verification.summary.clone());
286 if !verification.passed {
287 return Err(failure(
288 &draft,
289 "verification_failed",
290 "release readiness failed: verification is not green",
291 verification
292 .details
293 .clone()
294 .or_else(|| Some(verification.summary.clone())),
295 ));
296 }
297
298 let commit_summaries = collect_commit_summaries(project_root, previous_tag.as_deref())
299 .map_err(|error| {
300 failure(
301 &draft,
302 "commit_summary_failed",
303 "failed to assemble the release commit summary",
304 Some(error.to_string()),
305 )
306 })?;
307
308 let context = ReleaseContext {
309 metadata,
310 branch,
311 git_ref,
312 previous_tag,
313 commits_since_previous,
314 commit_summaries,
315 };
316
317 let notes = render_release_notes(&context, &verification);
318 let notes_path = write_release_notes(project_root, &context, ¬es).map_err(|error| {
319 failure(
320 &draft,
321 "notes_write_failed",
322 "failed to write release notes",
323 Some(error.to_string()),
324 )
325 })?;
326 draft.notes_path = Some(notes_path.display().to_string());
327
328 git_ok(
329 project_root,
330 &[
331 "tag",
332 "-a",
333 context.metadata.tag.as_str(),
334 "-F",
335 notes_path.to_string_lossy().as_ref(),
336 ],
337 )
338 .map_err(|error| {
339 failure(
340 &draft,
341 "tag_creation_failed",
342 "failed to create the annotated release tag",
343 Some(error.to_string()),
344 )
345 })?;
346
347 let record = success_record(&context, &verification, ¬es_path);
348 Ok((record, notes))
349}
350
351fn releases_dir(project_root: &Path) -> PathBuf {
352 project_root.join(RELEASES_DIR)
353}
354
355fn resolve_verification_command(
356 project_root: &Path,
357 override_command: Option<&str>,
358) -> Result<String> {
359 if let Some(command) = override_command {
360 return Ok(command.trim().to_string());
361 }
362
363 let team_config_path = crate::team::team_config_path(project_root);
364 if !team_config_path.exists() {
365 return Ok("cargo test".to_string());
366 }
367
368 let config = TeamConfig::load(&team_config_path)
369 .with_context(|| format!("failed to load {}", team_config_path.display()))?;
370 let command = config
371 .workflow_policy
372 .verification
373 .test_command
374 .unwrap_or_else(|| "cargo test".to_string());
375 if command.trim().is_empty() {
376 bail!(
377 "{} sets workflow_policy.verification.test_command to an empty value",
378 team_config_path.display()
379 );
380 }
381 Ok(command)
382}
383
384fn load_release_metadata(
385 project_root: &Path,
386 requested_tag: Option<&str>,
387) -> Result<ReleaseMetadata> {
388 #[derive(Deserialize)]
389 struct CargoToml {
390 package: Option<CargoPackage>,
391 }
392
393 #[derive(Deserialize)]
394 struct CargoPackage {
395 name: Option<String>,
396 version: Option<String>,
397 }
398
399 let cargo_toml_path = project_root.join("Cargo.toml");
400 let content = fs::read_to_string(&cargo_toml_path)
401 .with_context(|| format!("failed to read {}", cargo_toml_path.display()))?;
402 let parsed: CargoToml = toml::from_str(&content)
403 .with_context(|| format!("failed to parse {}", cargo_toml_path.display()))?;
404 let package = parsed
405 .package
406 .context("Cargo.toml is missing `[package]` release metadata")?;
407 let package_name = package
408 .name
409 .filter(|value| !value.trim().is_empty())
410 .context("Cargo.toml package.name is required for releases")?;
411 let version = package
412 .version
413 .filter(|value| !value.trim().is_empty())
414 .context("Cargo.toml package.version is required for releases")?;
415
416 let (changelog_heading, changelog_body) = load_changelog_entry(project_root, &version)?;
417 let tag = requested_tag
418 .map(str::trim)
419 .filter(|value| !value.is_empty())
420 .map(str::to_string)
421 .unwrap_or_else(|| format!("v{version}"));
422
423 Ok(ReleaseMetadata {
424 package_name,
425 version,
426 tag,
427 changelog_heading,
428 changelog_body,
429 })
430}
431
432fn load_changelog_entry(project_root: &Path, version: &str) -> Result<(String, String)> {
433 let changelog_path = project_root.join("CHANGELOG.md");
434 let content = fs::read_to_string(&changelog_path)
435 .with_context(|| format!("failed to read {}", changelog_path.display()))?;
436 let lines: Vec<&str> = content.lines().collect();
437 let heading_prefix = format!("## {version}");
438
439 let Some(start_index) = lines
440 .iter()
441 .position(|line| line.trim_start().starts_with(&heading_prefix))
442 else {
443 bail!(
444 "CHANGELOG.md is missing a release heading for version {}",
445 version
446 );
447 };
448
449 let heading = lines[start_index].trim().to_string();
450 let end_index = lines[start_index + 1..]
451 .iter()
452 .position(|line| line.trim_start().starts_with("## "))
453 .map(|offset| start_index + 1 + offset)
454 .unwrap_or(lines.len());
455 let body = lines[start_index + 1..end_index]
456 .join("\n")
457 .trim()
458 .to_string();
459 if body.is_empty() {
460 bail!(
461 "CHANGELOG.md release entry for version {} is empty",
462 version
463 );
464 }
465
466 Ok((heading, body))
467}
468
469fn git_stdout(project_root: &Path, args: &[&str]) -> Result<String> {
470 let output = Command::new("git")
471 .arg("-C")
472 .arg(project_root)
473 .args(args)
474 .output()
475 .with_context(|| format!("failed to run git {}", args.join(" ")))?;
476 if !output.status.success() {
477 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
478 bail!(
479 "git {} failed{}",
480 args.join(" "),
481 if stderr.is_empty() {
482 String::new()
483 } else {
484 format!(": {stderr}")
485 }
486 );
487 }
488 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
489}
490
491fn git_ok(project_root: &Path, args: &[&str]) -> Result<()> {
492 let _ = git_stdout(project_root, args)?;
493 Ok(())
494}
495
496fn git_ref_for_tag(project_root: &Path, tag: &str) -> Result<Option<String>> {
497 let output = Command::new("git")
498 .arg("-C")
499 .arg(project_root)
500 .args(["rev-parse", "-q", "--verify", &format!("refs/tags/{tag}")])
501 .output()
502 .with_context(|| format!("failed to inspect tag `{tag}`"))?;
503 match output.status.code() {
504 Some(0) => Ok(Some(
505 String::from_utf8_lossy(&output.stdout).trim().to_string(),
506 )),
507 Some(1) => Ok(None),
508 _ => {
509 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
510 bail!("git rev-parse tag `{tag}` failed: {stderr}");
511 }
512 }
513}
514
515fn latest_git_tag(project_root: &Path) -> Result<Option<String>> {
516 let output = Command::new("git")
517 .arg("-C")
518 .arg(project_root)
519 .args(["describe", "--tags", "--abbrev=0"])
520 .output()
521 .context("failed to resolve the latest git tag")?;
522 if output.status.success() {
523 return Ok(Some(
524 String::from_utf8_lossy(&output.stdout).trim().to_string(),
525 ));
526 }
527
528 let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
529 if stderr.contains("no names found") || stderr.contains("no tags can describe") {
530 return Ok(None);
531 }
532
533 bail!(
534 "git describe --tags --abbrev=0 failed: {}",
535 String::from_utf8_lossy(&output.stderr).trim()
536 )
537}
538
539fn count_commits_since(project_root: &Path, previous_tag: Option<&str>) -> Result<usize> {
540 let count = match previous_tag {
541 Some(tag) => {
542 let range = format!("{tag}..main");
543 git_stdout(project_root, &["rev-list", "--count", &range])?
544 }
545 None => git_stdout(project_root, &["rev-list", "--count", "main"])?,
546 };
547 count
548 .parse::<usize>()
549 .with_context(|| format!("failed to parse commit count `{count}`"))
550}
551
552fn collect_commit_summaries(
553 project_root: &Path,
554 previous_tag: Option<&str>,
555) -> Result<Vec<String>> {
556 let output = match previous_tag {
557 Some(tag) => {
558 let range = format!("{tag}..main");
559 git_stdout(
560 project_root,
561 &["log", "--no-merges", "--format=%h %s", &range],
562 )?
563 }
564 None => git_stdout(
565 project_root,
566 &["log", "--no-merges", "--format=%h %s", "-n", "20", "main"],
567 )?,
568 };
569 Ok(output
570 .lines()
571 .map(str::trim)
572 .filter(|line| !line.is_empty())
573 .map(str::to_string)
574 .collect())
575}
576
577fn render_release_notes(context: &ReleaseContext, verification: &ReleaseVerification) -> String {
578 let mut out = String::new();
579 out.push_str("# Release Notes\n\n");
580 out.push_str(&format!("- Package: {}\n", context.metadata.package_name));
581 out.push_str(&format!("- Version: {}\n", context.metadata.version));
582 out.push_str(&format!("- Tag: {}\n", context.metadata.tag));
583 out.push_str(&format!("- Git Ref: {}\n", context.git_ref));
584 out.push_str(&format!("- Branch: {}\n", context.branch));
585 out.push_str(&format!(
586 "- Previous Tag: {}\n",
587 context.previous_tag.as_deref().unwrap_or("none")
588 ));
589 out.push_str(&format!(
590 "- Commits Since Previous Tag: {}\n",
591 context.commits_since_previous
592 ));
593 out.push_str(&format!(
594 "- Verification Command: {}\n",
595 verification.command
596 ));
597 out.push_str(&format!(
598 "- Verification Summary: {}\n",
599 verification.summary
600 ));
601 out.push_str(&format!(
602 "- Generated: {}\n\n",
603 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
604 ));
605
606 out.push_str("## Changelog\n\n");
607 out.push_str(&context.metadata.changelog_heading);
608 out.push_str("\n\n");
609 out.push_str(&context.metadata.changelog_body);
610 out.push_str("\n\n");
611
612 out.push_str("## Included Commits\n\n");
613 if context.commit_summaries.is_empty() {
614 out.push_str("- No commits were found in the selected release window.\n");
615 } else {
616 for commit in &context.commit_summaries {
617 out.push_str("- ");
618 out.push_str(commit);
619 out.push('\n');
620 }
621 }
622
623 out
624}
625
626fn render_attempt_report(record: &ReleaseRecord) -> String {
627 let mut out = String::new();
628 out.push_str("# Release Attempt\n\n");
629 out.push_str(&format!(
630 "- Status: {}\n",
631 if record.success { "success" } else { "failure" }
632 ));
633 if let Some(package_name) = record.package_name.as_deref() {
634 out.push_str(&format!("- Package: {package_name}\n"));
635 }
636 if let Some(version) = record.version.as_deref() {
637 out.push_str(&format!("- Version: {version}\n"));
638 }
639 if let Some(tag) = record.tag.as_deref() {
640 out.push_str(&format!("- Tag: {tag}\n"));
641 }
642 if let Some(git_ref) = record.git_ref.as_deref() {
643 out.push_str(&format!("- Git Ref: {git_ref}\n"));
644 }
645 if let Some(branch) = record.branch.as_deref() {
646 out.push_str(&format!("- Branch: {branch}\n"));
647 }
648 if let Some(previous_tag) = record.previous_tag.as_deref() {
649 out.push_str(&format!("- Previous Tag: {previous_tag}\n"));
650 }
651 if let Some(commits_since_previous) = record.commits_since_previous {
652 out.push_str(&format!(
653 "- Commits Since Previous Tag: {commits_since_previous}\n"
654 ));
655 }
656 if let Some(command) = record.verification_command.as_deref() {
657 out.push_str(&format!("- Verification Command: {command}\n"));
658 }
659 if let Some(summary) = record.verification_summary.as_deref() {
660 out.push_str(&format!("- Verification Summary: {summary}\n"));
661 }
662 out.push_str(&format!("- Timestamp: {}\n\n", record.ts));
663 out.push_str("## Outcome\n\n");
664 out.push_str(&record.reason);
665 out.push_str("\n\n");
666 if let Some(details) = record.details.as_deref() {
667 out.push_str("## Details\n\n");
668 out.push_str("```\n");
669 out.push_str(details.trim());
670 out.push_str("\n```\n");
671 }
672 if let Some(path) = record.notes_path.as_deref() {
673 out.push_str("\n## Release Notes Path\n\n");
674 out.push_str(path);
675 out.push('\n');
676 }
677 out
678}
679
680fn write_release_notes(
681 project_root: &Path,
682 context: &ReleaseContext,
683 notes: &str,
684) -> Result<PathBuf> {
685 let dir = releases_dir(project_root);
686 fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
687 let path = dir.join(format!("{}.md", context.metadata.tag));
688 fs::write(&path, notes).with_context(|| format!("failed to write {}", path.display()))?;
689 Ok(path)
690}
691
692fn write_latest_report(project_root: &Path, report_markdown: &str) -> Result<()> {
693 let dir = releases_dir(project_root);
694 fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
695 let latest_path = dir.join(RELEASE_LATEST_MARKDOWN);
696 fs::write(&latest_path, report_markdown)
697 .with_context(|| format!("failed to write {}", latest_path.display()))?;
698 Ok(())
699}
700
701fn persist_release_record(project_root: &Path, record: &ReleaseRecord) -> Result<()> {
702 let dir = releases_dir(project_root);
703 fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
704
705 let history_path = dir.join(RELEASE_HISTORY_FILE);
706 let mut history = OpenOptions::new()
707 .create(true)
708 .append(true)
709 .open(&history_path)
710 .with_context(|| format!("failed to open {}", history_path.display()))?;
711 writeln!(history, "{}", serde_json::to_string(record)?)
712 .with_context(|| format!("failed to append {}", history_path.display()))?;
713
714 let latest_path = dir.join(RELEASE_LATEST_JSON);
715 fs::write(&latest_path, serde_json::to_vec_pretty(record)?)
716 .with_context(|| format!("failed to write {}", latest_path.display()))?;
717 Ok(())
718}
719
720fn emit_release_record(project_root: &Path, record: &ReleaseRecord) -> Result<()> {
721 let event = if record.success {
722 TeamEvent::release_succeeded(
723 record.version.as_deref().unwrap_or("unknown"),
724 record.git_ref.as_deref().unwrap_or("unknown"),
725 record.tag.as_deref().unwrap_or("unknown"),
726 record.notes_path.as_deref(),
727 )
728 } else {
729 TeamEvent::release_failed(
730 record.version.as_deref(),
731 record.git_ref.as_deref(),
732 record.tag.as_deref(),
733 &record.reason,
734 record.details.as_deref(),
735 )
736 };
737
738 let mut sink = EventSink::new(&crate::team::team_events_path(project_root))?;
739 sink.emit(event.clone())?;
740
741 let conn = crate::team::telemetry_db::open(project_root)?;
742 crate::team::telemetry_db::insert_event(&conn, &event)?;
743 Ok(())
744}
745
746fn success_record(
747 context: &ReleaseContext,
748 verification: &ReleaseVerification,
749 notes_path: &Path,
750) -> ReleaseRecord {
751 ReleaseRecord {
752 ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
753 package_name: Some(context.metadata.package_name.clone()),
754 version: Some(context.metadata.version.clone()),
755 tag: Some(context.metadata.tag.clone()),
756 git_ref: Some(context.git_ref.clone()),
757 branch: Some(context.branch.clone()),
758 previous_tag: context.previous_tag.clone(),
759 commits_since_previous: Some(context.commits_since_previous),
760 verification_command: Some(verification.command.clone()),
761 verification_summary: Some(verification.summary.clone()),
762 success: true,
763 reason: format!(
764 "created annotated tag `{}` from clean green main",
765 context.metadata.tag
766 ),
767 details: Some(
768 serde_json::json!({
769 "previous_tag": context.previous_tag,
770 "commits_since_previous": context.commits_since_previous,
771 "notes_path": notes_path.display().to_string(),
772 })
773 .to_string(),
774 ),
775 notes_path: Some(notes_path.display().to_string()),
776 }
777}
778
779fn failure(
780 draft: &ReleaseDraft,
781 reason_code: &str,
782 message: &str,
783 details: Option<String>,
784) -> ReleaseFailure {
785 let record = ReleaseRecord {
786 ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
787 package_name: draft.package_name.clone(),
788 version: draft.version.clone(),
789 tag: draft.tag.clone(),
790 git_ref: draft.git_ref.clone(),
791 branch: draft.branch.clone(),
792 previous_tag: draft.previous_tag.clone(),
793 commits_since_previous: draft.commits_since_previous,
794 verification_command: draft.verification_command.clone(),
795 verification_summary: draft.verification_summary.clone(),
796 success: false,
797 reason: reason_code.to_string(),
798 details,
799 notes_path: draft.notes_path.clone(),
800 };
801 let report_markdown = render_attempt_report(&record);
802 ReleaseFailure {
803 record,
804 report_markdown,
805 message: message.to_string(),
806 }
807}
808
809fn trimmed_output(output: &str) -> Option<String> {
810 let trimmed = output.trim();
811 if trimmed.is_empty() {
812 None
813 } else {
814 Some(trimmed.to_string())
815 }
816}
817
818#[cfg(test)]
819mod tests {
820 use super::*;
821 use std::sync::Arc;
822 use std::sync::atomic::{AtomicUsize, Ordering};
823
824 struct StubVerifier {
825 result: ReleaseVerification,
826 calls: Arc<AtomicUsize>,
827 }
828
829 impl VerificationRunner for StubVerifier {
830 fn run(&self, _project_root: &Path) -> Result<ReleaseVerification> {
831 self.calls.fetch_add(1, Ordering::SeqCst);
832 Ok(self.result.clone())
833 }
834 }
835
836 fn git(repo: &Path, args: &[&str]) {
837 let status = Command::new("git")
838 .arg("-C")
839 .arg(repo)
840 .args(args)
841 .status()
842 .unwrap();
843 assert!(status.success(), "git {:?} failed", args);
844 }
845
846 fn git_output(repo: &Path, args: &[&str]) -> String {
847 let output = Command::new("git")
848 .arg("-C")
849 .arg(repo)
850 .args(args)
851 .output()
852 .unwrap();
853 assert!(output.status.success(), "git {:?} failed", args);
854 String::from_utf8_lossy(&output.stdout).trim().to_string()
855 }
856
857 fn init_repo() -> tempfile::TempDir {
858 let tmp = tempfile::tempdir().unwrap();
859 fs::write(
860 tmp.path().join("Cargo.toml"),
861 "[package]\nname = \"batty\"\nversion = \"0.10.0\"\nedition = \"2024\"\n",
862 )
863 .unwrap();
864 fs::write(
865 tmp.path().join("CHANGELOG.md"),
866 "# Changelog\n\n## 0.10.0 - 2026-04-10\n\n- Ship release automation.\n\n## 0.9.0 - 2026-04-07\n\n- Previous release.\n",
867 )
868 .unwrap();
869 fs::write(tmp.path().join("README.md"), "hello\n").unwrap();
870 git(tmp.path(), &["init", "-b", "main"]);
871 git(tmp.path(), &["config", "user.name", "Batty Tests"]);
872 git(tmp.path(), &["config", "user.email", "batty@example.com"]);
873 git(tmp.path(), &["add", "."]);
874 git(tmp.path(), &["commit", "-m", "Initial commit"]);
875 git(tmp.path(), &["tag", "v0.9.0"]);
876 fs::write(tmp.path().join("src.txt"), "feature\n").unwrap();
877 git(tmp.path(), &["add", "src.txt"]);
878 git(tmp.path(), &["commit", "-m", "Add release-ready change"]);
879 tmp
880 }
881
882 #[test]
883 fn release_fails_when_changelog_entry_is_missing() {
884 let tmp = tempfile::tempdir().unwrap();
885 fs::write(
886 tmp.path().join("Cargo.toml"),
887 "[package]\nname = \"batty\"\nversion = \"0.10.0\"\nedition = \"2024\"\n",
888 )
889 .unwrap();
890 fs::write(
891 tmp.path().join("CHANGELOG.md"),
892 "# Changelog\n\n## 0.9.0\n\n- old.\n",
893 )
894 .unwrap();
895 git(tmp.path(), &["init", "-b", "main"]);
896 git(tmp.path(), &["config", "user.name", "Batty Tests"]);
897 git(tmp.path(), &["config", "user.email", "batty@example.com"]);
898 let calls = Arc::new(AtomicUsize::new(0));
899 let verifier = StubVerifier {
900 result: ReleaseVerification {
901 command: "cargo test".to_string(),
902 passed: true,
903 summary: "ok".to_string(),
904 details: None,
905 },
906 calls: calls.clone(),
907 };
908
909 let error = run_release_with_verifier(tmp.path(), None, &verifier).unwrap_err();
910 assert_eq!(error.record.reason, "missing_release_metadata");
911 assert_eq!(calls.load(Ordering::SeqCst), 0);
912 }
913
914 #[test]
915 fn release_fails_when_repo_is_dirty() {
916 let tmp = init_repo();
917 fs::write(tmp.path().join("README.md"), "dirty\n").unwrap();
918 let calls = Arc::new(AtomicUsize::new(0));
919 let verifier = StubVerifier {
920 result: ReleaseVerification {
921 command: "cargo test".to_string(),
922 passed: true,
923 summary: "ok".to_string(),
924 details: None,
925 },
926 calls: calls.clone(),
927 };
928
929 let error = run_release_with_verifier(tmp.path(), None, &verifier).unwrap_err();
930 assert_eq!(error.record.reason, "dirty_main");
931 assert_eq!(calls.load(Ordering::SeqCst), 0);
932 }
933
934 #[test]
935 fn release_fails_when_verification_is_not_green() {
936 let tmp = init_repo();
937 let calls = Arc::new(AtomicUsize::new(0));
938 let verifier = StubVerifier {
939 result: ReleaseVerification {
940 command: "cargo test".to_string(),
941 passed: false,
942 summary: "1 tests failed: suite::it_breaks".to_string(),
943 details: Some("suite::it_breaks".to_string()),
944 },
945 calls: calls.clone(),
946 };
947
948 let error = run_release_with_verifier(tmp.path(), None, &verifier).unwrap_err();
949 assert_eq!(error.record.reason, "verification_failed");
950 assert_eq!(error.record.details.as_deref(), Some("suite::it_breaks"));
951 assert_eq!(calls.load(Ordering::SeqCst), 1);
952 }
953
954 #[test]
955 fn render_release_notes_includes_changelog_and_verification() {
956 let context = ReleaseContext {
957 metadata: ReleaseMetadata {
958 package_name: "batty".to_string(),
959 version: "0.10.0".to_string(),
960 tag: "v0.10.0".to_string(),
961 changelog_heading: "## 0.10.0 - 2026-04-10".to_string(),
962 changelog_body: "- Ship release automation.".to_string(),
963 },
964 branch: "main".to_string(),
965 git_ref: "abc123".to_string(),
966 previous_tag: Some("v0.9.0".to_string()),
967 commits_since_previous: 2,
968 commit_summaries: vec!["abc123 Improve release command".to_string()],
969 };
970 let verification = ReleaseVerification {
971 command: "cargo test".to_string(),
972 passed: true,
973 summary: "cargo test passed".to_string(),
974 details: None,
975 };
976
977 let notes = render_release_notes(&context, &verification);
978 assert!(notes.contains("Version: 0.10.0"));
979 assert!(notes.contains("Tag: v0.10.0"));
980 assert!(notes.contains("Git Ref: abc123"));
981 assert!(notes.contains("Verification Summary: cargo test passed"));
982 assert!(notes.contains("## 0.10.0 - 2026-04-10"));
983 assert!(notes.contains("abc123 Improve release command"));
984 }
985
986 #[test]
987 fn release_success_creates_tag_notes_and_recordable_context() {
988 let tmp = init_repo();
989 let calls = Arc::new(AtomicUsize::new(0));
990 let verifier = StubVerifier {
991 result: ReleaseVerification {
992 command: "cargo test".to_string(),
993 passed: true,
994 summary: "cargo test passed".to_string(),
995 details: None,
996 },
997 calls: calls.clone(),
998 };
999
1000 let (record, report_markdown) =
1001 run_release_with_verifier(tmp.path(), None, &verifier).unwrap();
1002 let tag = record.tag.clone().unwrap();
1003 let notes_path = PathBuf::from(record.notes_path.clone().unwrap());
1004
1005 assert!(record.success);
1006 assert_eq!(record.version.as_deref(), Some("0.10.0"));
1007 assert_eq!(record.branch.as_deref(), Some("main"));
1008 assert_eq!(record.previous_tag.as_deref(), Some("v0.9.0"));
1009 assert!(notes_path.exists());
1010 assert_eq!(git_output(tmp.path(), &["tag", "--list", &tag]), tag);
1011 assert!(report_markdown.contains("## Changelog"));
1012 assert_eq!(calls.load(Ordering::SeqCst), 1);
1013
1014 persist_release_record(tmp.path(), &record).unwrap();
1015 write_latest_report(tmp.path(), &report_markdown).unwrap();
1016 emit_release_record(tmp.path(), &record).unwrap();
1017
1018 let events = fs::read_to_string(
1019 tmp.path()
1020 .join(".batty")
1021 .join("team_config")
1022 .join("events.jsonl"),
1023 )
1024 .unwrap();
1025 assert!(events.contains("\"event\":\"release_succeeded\""));
1026 assert!(events.contains("\"version\":\"0.10.0\""));
1027 assert!(events.contains("\"git_ref\""));
1028 }
1029
1030 #[test]
1031 fn release_uses_tag_override() {
1032 let tmp = init_repo();
1033 let calls = Arc::new(AtomicUsize::new(0));
1034 let verifier = StubVerifier {
1035 result: ReleaseVerification {
1036 command: "cargo test".to_string(),
1037 passed: true,
1038 summary: "cargo test passed".to_string(),
1039 details: None,
1040 },
1041 calls: calls.clone(),
1042 };
1043
1044 let (record, _) =
1045 run_release_with_verifier(tmp.path(), Some("batty-2026-04-10"), &verifier).unwrap();
1046 assert_eq!(record.tag.as_deref(), Some("batty-2026-04-10"));
1047 assert_eq!(
1048 git_output(tmp.path(), &["tag", "--list", "batty-2026-04-10"]),
1049 "batty-2026-04-10"
1050 );
1051 }
1052
1053 #[test]
1054 fn latest_record_reads_latest_json_snapshot() {
1055 let tmp = tempfile::tempdir().unwrap();
1056 let record = ReleaseRecord {
1057 ts: "2026-04-10T00:00:00Z".to_string(),
1058 package_name: Some("batty".to_string()),
1059 version: Some("0.10.0".to_string()),
1060 tag: Some("v0.10.0".to_string()),
1061 git_ref: Some("abc123".to_string()),
1062 branch: Some("main".to_string()),
1063 previous_tag: Some("v0.9.0".to_string()),
1064 commits_since_previous: Some(12),
1065 verification_command: Some("cargo test".to_string()),
1066 verification_summary: Some("cargo test passed".to_string()),
1067 success: true,
1068 reason: "ok".to_string(),
1069 details: None,
1070 notes_path: Some("/tmp/v0.10.0.md".to_string()),
1071 };
1072 persist_release_record(tmp.path(), &record).unwrap();
1073
1074 let loaded = latest_record(tmp.path()).unwrap().unwrap();
1075 assert_eq!(loaded, record);
1076 }
1077
1078 #[test]
1079 fn configured_verification_runner_respects_override_command() {
1080 let tmp = init_repo();
1081 let runner = ConfiguredVerificationRunner {
1082 command_override: Some(
1083 "printf 'test result: ok. 1 passed; 0 failed; 0 ignored;\\n'".to_string(),
1084 ),
1085 };
1086
1087 let verification = runner.run(tmp.path()).unwrap();
1088 assert_eq!(
1089 verification.command,
1090 "printf 'test result: ok. 1 passed; 0 failed; 0 ignored;\\n'"
1091 );
1092 assert!(verification.passed);
1093 assert!(verification.summary.contains("1 passed"));
1094 }
1095}