1use super::_prelude::*;
2use origen::utility::github::{dispatch_workflow, get_latest_workflow_dispatch, WorkflowRun};
3use origen::utility::release_scribe::{ReleaseProduct, ReleaseScribe};
4use origen_metal::dialoguer::{Confirm, Input, Select};
5use origen_metal::utils::crates_io::is_crate_version_available;
6use origen_metal::utils::pypi::is_package_version_available;
7use origen_metal::utils::revision_control::supported::git;
8use origen_metal::utils::revision_control::RevisionControlAPI;
9use origen_metal::utils::version::{ReleaseType, Version, VersionWithTOML};
10use serde::{Deserialize, Serialize};
11use std::collections::HashSet;
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::process::Command;
15use std::thread;
16use std::time::Duration;
17
18pub const BASE_CMD: &'static str = "rc";
19const PRODUCTS: [&str; 2] = ["origen", "origen-metal"];
20const RELEASE_TYPES: [&str; 6] = [
21 "development",
22 "patch",
23 "minor",
24 "major",
25 "production",
26 "current",
27];
28
29gen_core_cmd_funcs__no_exts__no_app_opts!(
30 BASE_CMD,
31 "Revision-control and release operations",
32 { |cmd: App| cmd.arg_required_else_help(true) },
33 core_subcmd__no_exts__no_app_opts!("tag", "Prepare and release Origen products", {
34 |cmd: App| {
35 cmd.arg(
36 Arg::new("product")
37 .long("product")
38 .short('p')
39 .value_parser(PRODUCTS)
40 .action(clap::ArgAction::Append)
41 .help("Product to release; repeat to release both products"),
42 )
43 .arg(Arg::new("type").long("type").short('t').value_parser(RELEASE_TYPES).action(SetArg).conflicts_with_all(&["origen-type", "metal-type"]).help("Release type for a single selected product"))
44 .arg(Arg::new("origen-type").long("origen-type").value_parser(RELEASE_TYPES).action(SetArg).help("Origen release type for a combined release"))
45 .arg(Arg::new("metal-type").long("metal-type").value_parser(RELEASE_TYPES).action(SetArg).help("Origen Metal release type for a combined release"))
46 .arg(Arg::new("note").long("note").short('n').action(SetArg).help("Release-note text"))
47 .arg(Arg::new("file").long("file").short('f').action(SetArg).conflicts_with("note").help("Read the release note from this file"))
48 .arg(Arg::new("author").long("author").action(SetArg).help("Override the configured Git release author"))
49 .arg(Arg::new("dry-run").long("dry-run").action(SetArgTrue).help("Validate and display the release plan without changing files or external state"))
50 .arg(Arg::new("local").long("local").action(SetArgTrue).conflicts_with("dry-run").help("Prepare versions and histories locally without commit, tag, push, publication, or deployment"))
51 .arg(Arg::new("non-interactive").long("non-interactive").action(SetArgTrue).help("Fail instead of prompting when required release inputs are absent"))
52 .arg(Arg::new("yes").long("yes").short('y').action(SetArgTrue).help("Accept the final release plan"))
53 .arg(Arg::new("resume").long("resume").action(SetArg).value_name("RELEASE_ID").conflicts_with("status").help("Resume or redispatch a prepared release by its displayed release ID"))
54 .arg(Arg::new("status").long("status").action(SetArg).value_name("RELEASE_ID").conflicts_with_all(&["resume", "dry-run", "local"]).help("Display the remote workflow status for a prepared release"))
55 .arg(Arg::new("wait").long("wait").action(SetArgTrue).help("Wait for the remote release workflow to finish"))
56 .arg(Arg::new("allow-local-changes").long("allow-local-changes").action(SetArgTrue).help("Allow a dirty workspace for local or dry-run simulation only"))
57 }
58 })
59);
60
61#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
62enum Product {
63 Origen,
64 Metal,
65}
66
67impl Product {
68 fn parse(value: &str) -> Self {
69 match value {
70 "origen" => Self::Origen,
71 "origen-metal" => Self::Metal,
72 _ => unreachable!(),
73 }
74 }
75
76 fn name(&self) -> &'static str {
77 match self {
78 Self::Origen => "Origen",
79 Self::Metal => "Origen Metal",
80 }
81 }
82
83 fn release_product(&self) -> ReleaseProduct {
84 match self {
85 Self::Origen => ReleaseProduct::Origen,
86 Self::Metal => ReleaseProduct::OrigenMetal,
87 }
88 }
89}
90
91struct PlannedProduct {
92 product: Product,
93 version: VersionWithTOML,
94 cargo_versions: Vec<VersionWithTOML>,
95 release_type: String,
96 workflow: Option<String>,
97}
98
99#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
100struct PublishRequest {
101 product: Product,
102 tag: String,
103 version: String,
104 workflow: Option<String>,
105}
106
107trait ReleaseEffects {
108 fn validate(&mut self) -> Result<()>;
109 fn commit(&mut self) -> Result<()>;
110 fn tag(&mut self, request: &PublishRequest) -> Result<()>;
111 fn dispatch_release(
112 &mut self,
113 release_id: &str,
114 requests: &[PublishRequest],
115 ) -> Result<WorkflowRun>;
116}
117
118struct ProductionEffects<'a> {
119 rc: &'a origen_metal::utils::revision_control::RevisionControl,
120 repo: &'a GithubRepository,
121 release_branch: String,
122 requests: Vec<PublishRequest>,
123 paths: Vec<PathBuf>,
124 workflow: String,
125}
126
127impl ProductionEffects<'_> {
128 fn find_dispatched_run(
129 &self,
130 previous_id: Option<u64>,
131 expected_ref: &str,
132 ) -> Result<WorkflowRun> {
133 for _ in 0..30 {
134 if let Ok(run) = get_latest_workflow_dispatch(
135 &self.repo.owner,
136 &self.repo.name,
137 Some(&self.workflow),
138 ) {
139 if run.head_branch == expected_ref && previous_id.map_or(true, |id| run.id != id) {
140 return Ok(run);
141 }
142 }
143 thread::sleep(Duration::from_secs(2));
144 }
145 bail!(
146 "Timed out waiting for release workflow '{}' to start",
147 self.workflow
148 )
149 }
150}
151
152impl ReleaseEffects for ProductionEffects<'_> {
153 fn validate(&mut self) -> Result<()> {
154 validate_remote_preflight(self.rc, &self.release_branch, &self.requests)
155 }
156
157 fn commit(&mut self) -> Result<()> {
158 let paths: Vec<&Path> = self.paths.iter().map(|p| p.as_path()).collect();
159 self.rc
160 .checkin(Some(paths), "Update versions and release histories", false)?;
161 Ok(())
162 }
163
164 fn tag(&mut self, request: &PublishRequest) -> Result<()> {
165 self.rc.tag(
166 &request.tag,
167 false,
168 Some(&format!(
169 "Release {} {}",
170 request.product.name(),
171 request.version
172 )),
173 )
174 }
175
176 fn dispatch_release(
177 &mut self,
178 release_id: &str,
179 requests: &[PublishRequest],
180 ) -> Result<WorkflowRun> {
181 let latest =
182 get_latest_workflow_dispatch(&self.repo.owner, &self.repo.name, Some(&self.workflow))
183 .ok();
184 if let Some(run) = latest.as_ref() {
185 if run.head_branch == self.release_branch && !run.completed() {
186 return Ok(run.refresh()?);
187 }
188 }
189 let previous_id = latest.map(|run| run.id);
190 let origen = requests
191 .iter()
192 .find(|request| request.product == Product::Origen);
193 let metal = requests
194 .iter()
195 .find(|request| request.product == Product::Metal);
196 let inputs = serde_json::json!({
197 "release_id": release_id,
198 "origen_ref": origen.map(|request| request.tag.as_str()).unwrap_or(""),
199 "origen_version": origen.map(|request| request.version.as_str()).unwrap_or(""),
200 "metal_ref": metal.map(|request| request.tag.as_str()).unwrap_or(""),
201 "metal_version": metal.map(|request| request.version.as_str()).unwrap_or(""),
202 });
203 dispatch_workflow(
204 &self.repo.owner,
205 &self.repo.name,
206 &self.workflow,
207 &self.release_branch,
208 Some(inputs),
209 )?;
210 self.find_dispatched_run(previous_id, &self.release_branch)
211 }
212}
213
214#[derive(Debug, PartialEq)]
215struct GithubRepository {
216 owner: String,
217 name: String,
218}
219
220impl GithubRepository {
221 fn from_remote(remote: &str) -> Result<Self> {
222 let normalized = normalize_repository(remote);
223 let path = normalized
224 .strip_prefix("https://github.com/")
225 .ok_or_else(|| {
226 origen::Error::new(&format!(
227 "GitHub Actions provider requires a github.com remote, found '{}'",
228 remote
229 ))
230 })?;
231 let mut pieces = path.split('/');
232 let owner = pieces
233 .next()
234 .filter(|p| !p.is_empty())
235 .ok_or_else(|| origen::Error::new("GitHub repository owner is missing"))?;
236 let name = pieces
237 .next()
238 .filter(|p| !p.is_empty())
239 .ok_or_else(|| origen::Error::new("GitHub repository name is missing"))?;
240 if pieces.next().is_some() {
241 bail!("Unexpected GitHub repository path '{}'", path)
242 }
243 Ok(Self {
244 owner: owner.to_string(),
245 name: name.to_string(),
246 })
247 }
248}
249
250#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
251struct ReleaseState {
252 release_id: String,
253 source_remote: String,
254 release_branch: String,
255 provider: String,
256 completed: Vec<String>,
257 #[serde(default)]
258 workflow_run_id: Option<u64>,
259 #[serde(default)]
260 workflow_url: Option<String>,
261 requests: Vec<PublishRequest>,
262}
263
264impl ReleaseState {
265 fn new(
266 release_id: String,
267 requests: Vec<PublishRequest>,
268 source_remote: String,
269 release_branch: String,
270 provider: String,
271 ) -> Self {
272 Self {
273 release_id,
274 source_remote,
275 release_branch,
276 provider,
277 completed: Vec::new(),
278 workflow_run_id: None,
279 workflow_url: None,
280 requests,
281 }
282 }
283
284 fn load(path: &Path) -> Result<Self> {
285 Ok(toml::from_str(&fs::read_to_string(path)?)?)
286 }
287
288 fn save(&self, path: &Path) -> Result<()> {
289 if let Some(parent) = path.parent() {
290 fs::create_dir_all(parent)?;
291 }
292 fs::write(path, toml::to_string(self)?)?;
293 Ok(())
294 }
295
296 fn complete(&mut self, phase: &str) {
297 if !self.completed.iter().any(|p| p == phase) {
298 self.completed.push(phase.to_string());
299 }
300 }
301
302 fn is_complete(&self, phase: &str) -> bool {
303 self.completed.iter().any(|p| p == phase)
304 }
305}
306
307fn run_phase<F>(state: &mut ReleaseState, state_path: &Path, phase: &str, action: F) -> Result<()>
308where
309 F: FnOnce() -> Result<()>,
310{
311 if state.is_complete(phase) {
312 displayln!("Skipping completed release phase: {}", phase);
313 return Ok(());
314 }
315 displayln!("Running release phase: {}", phase);
316 action()?;
317 state.complete(phase);
318 state.save(state_path)?;
319 Ok(())
320}
321
322fn execute_remote_phases<E: ReleaseEffects>(
323 effects: &mut E,
324 requests: &[PublishRequest],
325 state: &mut ReleaseState,
326 state_path: &Path,
327) -> Result<()> {
328 run_phase(state, state_path, "remote_validated", || effects.validate())?;
329 run_phase(state, state_path, "committed", || effects.commit())?;
330 for request in requests {
331 run_phase(
332 state,
333 state_path,
334 &format!("tagged:{}", request.tag),
335 || effects.tag(request),
336 )?;
337 }
338 if state.workflow_run_id.is_none() {
339 displayln!("Running release phase: dispatched");
340 let run = effects.dispatch_release(&state.release_id, requests)?;
341 state.workflow_run_id = Some(run.id);
342 state.workflow_url = Some(run.html_url.clone());
343 state.complete("dispatched");
344 state.save(state_path)?;
345 } else {
346 displayln!("Skipping completed release phase: dispatched");
347 }
348 displayln!(
349 "Release workflow dispatched: {}",
350 state.workflow_url.as_deref().unwrap_or("URL unavailable")
351 );
352 Ok(())
353}
354
355fn monitor_release_workflow(
356 repo: &GithubRepository,
357 state: &mut ReleaseState,
358 state_path: &Path,
359 wait: bool,
360) -> Result<()> {
361 let run_id = state.workflow_run_id.ok_or_else(|| {
362 origen::Error::new(&format!(
363 "Release '{}' has not dispatched a workflow",
364 state.release_id
365 ))
366 })?;
367 let mut run = origen::utility::github::get_workflow_run_by_id(&repo.owner, &repo.name, run_id)?;
368 if wait {
369 for _ in 0..1440 {
370 if run.completed() {
371 break;
372 }
373 thread::sleep(Duration::from_secs(5));
374 run = run.refresh()?;
375 }
376 }
377 if wait && !run.completed() {
378 bail!("Timed out waiting for release workflow {}", run.html_url)
379 }
380 displayln!(
381 "Release workflow: {} ({})",
382 run.html_url,
383 if run.completed() {
384 run.conclusion.as_deref().unwrap_or("completed")
385 } else {
386 &run.status
387 }
388 );
389 if !run.completed() {
390 return Ok(());
391 }
392 if run.conclusion.as_deref() != Some("success") {
393 bail!(
394 "Release workflow failed: {} ({:?})",
395 run.html_url,
396 run.conclusion
397 )
398 }
399 state.complete("workflow_succeeded");
400 state.save(state_path)?;
401 Ok(())
402}
403
404pub(crate) fn run(invocation: &clap::ArgMatches) -> Result<()> {
405 if origen::app().is_none() && origen::STATUS.is_origen_present {
411 let core_app = origen::STATUS.origen_wksp_root.join("python/origen");
412 if !core_app.join("config/application.toml").is_file() {
413 bail!(
414 "O2 core application was not found at {}",
415 core_app.display()
416 )
417 }
418 let status = Command::new(std::env::current_exe()?)
419 .args(std::env::args().skip(1))
420 .current_dir(core_app)
421 .status()?;
422 std::process::exit(status.code().unwrap_or(1));
423 }
424
425 let (_, args) = invocation.subcommand().unwrap();
426 let non_interactive = *args.get_one::<bool>("non-interactive").unwrap();
427 let dry_run = *args.get_one::<bool>("dry-run").unwrap();
428 let local = *args.get_one::<bool>("local").unwrap();
429 let wait = *args.get_one::<bool>("wait").unwrap();
430
431 let app =
432 origen::app().ok_or_else(|| origen::Error::new("rc tag requires an Origen application"))?;
433 let (configured_remote, release_branch, release_config) = app.with_config(|config| {
434 let rc = config
435 .revision_control
436 .as_ref()
437 .ok_or_else(|| origen::Error::new("No revision-control configuration was found"))?;
438 let remote = rc
439 .get("remote")
440 .cloned()
441 .ok_or_else(|| origen::Error::new("revision_control.remote is required"))?;
442 let branch = rc
443 .get("release_branch")
444 .cloned()
445 .ok_or_else(|| origen::Error::new("revision_control.release_branch is required"))?;
446 let release = config
447 .release
448 .clone()
449 .ok_or_else(|| origen::Error::new("release configuration is required"))?;
450 Ok((remote, branch, release))
451 })?;
452 let provider = release_config
453 .get("provider")
454 .cloned()
455 .ok_or_else(|| origen::Error::new("release.provider is required"))?;
456 let release_workflow = release_config
457 .get("orchestrator_workflow")
458 .cloned()
459 .ok_or_else(|| origen::Error::new("release.orchestrator_workflow is required"))?;
460 let rc = app.rc()?;
461 let actual_remote = rc.remote_url()?;
462 if normalize_repository(&actual_remote) != normalize_repository(&configured_remote) {
463 bail!(
464 "Checkout origin '{}' does not match configured revision-control remote '{}'",
465 actual_remote,
466 configured_remote
467 )
468 }
469 let root = origen::STATUS.origen_wksp_root.clone();
470 if let Some(release_id) = args.get_one::<String>("status") {
471 let state_path = app
472 .root
473 .join(".origen/releases")
474 .join(format!("{}.toml", release_id));
475 if !state_path.is_file() {
476 bail!("No release state exists at {}", state_path.display())
477 }
478 let mut state = ReleaseState::load(&state_path)?;
479 let repository = GithubRepository::from_remote(&configured_remote)?;
480 monitor_release_workflow(&repository, &mut state, &state_path, wait)?;
481 return Ok(());
482 }
483 if let Some(release_id) = args.get_one::<String>("resume") {
484 if dry_run || local {
485 bail!("--resume continues a prepared remote release and cannot be combined with --dry-run or --local")
486 }
487 let state_path = app
488 .root
489 .join(".origen/releases")
490 .join(format!("{}.toml", release_id));
491 if !state_path.is_file() {
492 bail!(
493 "No resumable release state exists at {}",
494 state_path.display()
495 )
496 }
497 let mut state = ReleaseState::load(&state_path)?;
498 if state.release_id != *release_id
499 || !state.is_complete("workspace_validated")
500 || !state.is_complete("prepared")
501 {
502 bail!(
503 "Release state '{}' is not a completed preparation",
504 release_id
505 )
506 }
507 if normalize_repository(&state.source_remote) != normalize_repository(&configured_remote)
508 || state.release_branch != release_branch
509 || state.provider != provider
510 {
511 bail!(
512 "Configured release source/provider changed since '{}' was prepared",
513 release_id
514 )
515 }
516 if provider != "github_actions" {
517 bail!("Resume currently requires the github_actions release provider")
518 }
519 let repository = GithubRepository::from_remote(&configured_remote)?;
520 if state.workflow_run_id.is_some() {
521 let run = origen::utility::github::get_workflow_run_by_id(
522 &repository.owner,
523 &repository.name,
524 state.workflow_run_id.unwrap(),
525 )?;
526 if !run.completed() || run.conclusion.as_deref() == Some("success") {
527 monitor_release_workflow(&repository, &mut state, &state_path, wait)?;
528 return Ok(());
529 }
530 displayln!("Redispatching failed release workflow: {}", run.html_url);
531 state.workflow_run_id = None;
532 state.workflow_url = None;
533 state
534 .completed
535 .retain(|phase| phase != "dispatched" && phase != "workflow_succeeded");
536 state.save(&state_path)?;
537 }
538 let paths = release_paths(&root, &state.requests)?;
539 let requests = state.requests.clone();
540 let mut effects = ProductionEffects {
541 rc: &rc,
542 repo: &repository,
543 release_branch: state.release_branch.clone(),
544 requests: requests.clone(),
545 paths,
546 workflow: release_workflow.clone(),
547 };
548 execute_remote_phases(&mut effects, &requests, &mut state, &state_path)?;
549 if wait {
550 monitor_release_workflow(&repository, &mut state, &state_path, true)?;
551 }
552 displayln!("Release '{}' resumed successfully", release_id);
553 return Ok(());
554 }
555 let status = rc.status(None)?;
556 if status.is_modified() && !*args.get_one::<bool>("allow-local-changes").unwrap() {
557 bail!("Workspace has local changes; commit/stash them or use --allow-local-changes for a local simulation")
558 }
559
560 let products = selected_products(args, non_interactive)?;
561 if products.len() > 1 && args.get_one::<String>("type").is_some() {
562 bail!("--type is only valid for a single-product release; combined releases use --origen-type and --metal-type")
563 }
564 let note = release_note(args, non_interactive, &app.root)?;
565 let author = args
566 .get_one::<String>("author")
567 .cloned()
568 .or_else(|| git::config("name"))
569 .ok_or_else(|| {
570 origen::Error::new(
571 "Release author is required; configure Git user.name or pass --author",
572 )
573 })?;
574
575 let github_repository = if provider == "github_actions" {
576 Some(GithubRepository::from_remote(&configured_remote)?)
577 } else {
578 None
579 };
580 let mut plans = Vec::new();
581 for product in products {
582 let (path, cargo_paths) = match product {
583 Product::Origen => (
584 root.join("python/origen/pyproject.toml"),
585 vec![
586 root.join("rust/origen/Cargo.toml"),
587 root.join("rust/origen/cli/Cargo.toml"),
588 root.join("rust/pyapi/Cargo.toml"),
589 ],
590 ),
591 Product::Metal => (
592 root.join("python/origen_metal/pyproject.toml"),
593 vec![
594 root.join("rust/origen_metal/Cargo.toml"),
595 root.join("rust/pyapi_metal/Cargo.toml"),
596 ],
597 ),
598 };
599 let release_type = selected_release_type(args, product, non_interactive)?;
600 let mut version = Version::from_pyproject_with_toml_handle(path)?;
601 let package_repository = version
602 .get_other(&["project", "urls", "Repository"])?
603 .as_str()
604 .ok_or_else(|| origen::Error::new("project.urls.Repository must be a string"))?;
605 if normalize_repository(package_repository) != normalize_repository(&configured_remote) {
606 bail!(
607 "Package repository '{}' does not match configured revision-control remote '{}'",
608 package_repository,
609 configured_remote,
610 )
611 }
612 if release_type != "current" {
613 version.set_new_version(proposed_version(version.orig_version(), &release_type)?)?;
614 }
615 let mut cargo_versions = Vec::new();
616 for cargo_path in cargo_paths {
617 let mut cargo_version = Version::from_cargo_with_toml_handle(cargo_path)?;
618 if release_type != "current" {
619 let mut target = version.version().clone();
620 target.convert_to_semver();
621 cargo_version.set_new_version(target)?;
622 } else {
623 let mut cargo_current = cargo_version.orig_version().clone();
624 cargo_current.convert_to_pep440();
625 if cargo_current.to_string() != version.version().to_string() {
626 bail!(
627 "Cannot release current {} version {} because {} contains {}; increment or align the versions first",
628 product.name(),
629 version.version(),
630 cargo_version.source().display(),
631 cargo_version.orig_version(),
632 )
633 }
634 }
635 cargo_versions.push(cargo_version);
636 }
637 let workflow = if provider == "github_actions" {
638 let key = match product {
639 Product::Origen => "origen_workflow",
640 Product::Metal => "origen_metal_workflow",
641 };
642 Some(
643 release_config
644 .get(key)
645 .cloned()
646 .ok_or_else(|| origen::Error::new(&format!("release.{} is required", key)))?,
647 )
648 } else {
649 None
650 };
651 plans.push(PlannedProduct {
652 product,
653 version,
654 cargo_versions,
655 release_type,
656 workflow,
657 });
658 }
659
660 let metal_version = plans
661 .iter()
662 .find(|p| p.product == Product::Metal && p.release_type != "current")
663 .map(|p| p.version.version().to_string());
664 let updated_metal_requirement = metal_version.clone();
665 if let Some(metal_version) = metal_version {
666 if let Some(origen_plan) = plans.iter_mut().find(|p| p.product == Product::Origen) {
667 if origen_plan.release_type == "current" {
668 bail!("A combined release that changes Origen Metal must also increment Origen so its Metal dependency can be updated")
669 }
670 origen_plan
671 .version
672 .set_dependency("origen-metal", &format!("~={}", metal_version))?;
673 }
674 }
675
676 let release_id = plans
677 .iter()
678 .map(|p| {
679 format!(
680 "{}-v{}",
681 p.product.release_product().tag_prefix(),
682 p.version.version()
683 )
684 })
685 .collect::<Vec<String>>()
686 .join("__");
687 let mut requests: Vec<PublishRequest> = plans
688 .iter()
689 .map(|plan| PublishRequest {
690 product: plan.product,
691 tag: format!(
692 "{}-v{}",
693 plan.product.release_product().tag_prefix(),
694 plan.version.version()
695 ),
696 version: plan.version.version().to_string(),
697 workflow: plan.workflow.clone(),
698 })
699 .collect();
700 requests.sort_by_key(|request| match request.product {
701 Product::Metal => 0,
702 Product::Origen => 1,
703 });
704 let state_path = app
705 .root
706 .join(".origen/releases")
707 .join(format!("{}.toml", release_id));
708 let mut release_state = ReleaseState::new(
709 release_id,
710 requests.clone(),
711 configured_remote.clone(),
712 release_branch.clone(),
713 provider.clone(),
714 );
715
716 displayln!("Release Plan");
717 displayln!(" Release ID: {}", release_state.release_id);
718 for plan in &plans {
719 displayln!(
720 " {}: {} -> {} ({})",
721 plan.product.name(),
722 plan.version.orig_version(),
723 plan.version.version(),
724 plan.release_type
725 );
726 displayln!(
727 " tag: {}-v{}",
728 plan.product.release_product().tag_prefix(),
729 plan.version.version()
730 );
731 if let Some(workflow) = &plan.workflow {
732 displayln!(" workflow: {}", workflow);
733 }
734 }
735 displayln!(" Author: {}", author);
736 if plans.iter().any(|p| p.product == Product::Origen) {
737 if let Some(version) = &updated_metal_requirement {
738 displayln!(" Origen dependency: origen-metal~={}", version);
739 }
740 }
741 displayln!(" Source: {} ({})", configured_remote, release_branch);
742 displayln!(" Publication provider: {}", provider);
743 displayln!(" Orchestrator workflow: {}", release_workflow);
744 if let Some(repo) = &github_repository {
745 displayln!(" GitHub repository: {}/{}", repo.owner, repo.name);
746 }
747 displayln!(
748 " Mode: {}",
749 if dry_run {
750 "dry-run"
751 } else if local {
752 "local"
753 } else {
754 "release"
755 }
756 );
757 if release_state.is_complete("prepared") {
758 displayln!(" Resume: local preparation was already completed");
759 }
760
761 if !*args.get_one::<bool>("yes").unwrap() {
762 if non_interactive {
763 bail!("--non-interactive requires --yes after all release inputs are supplied")
764 }
765 if !Confirm::new()
766 .with_prompt("Proceed with this release plan?")
767 .default(false)
768 .interact()?
769 {
770 displayln!("Release cancelled");
771 return Ok(());
772 }
773 }
774
775 if dry_run {
776 displayln!("Dry run complete; no files, commits, tags, workflows, registries, or websites were changed");
777 return Ok(());
778 }
779 if !local && *args.get_one::<bool>("allow-local-changes").unwrap() {
780 bail!("--allow-local-changes is only permitted with --dry-run or --local")
781 }
782 if !local && provider != "github_actions" {
783 bail!(
784 "Unsupported remote release provider '{}'; use --local or configure github_actions",
785 provider
786 )
787 }
788 validate_uv_version()?;
789 run_phase(
790 &mut release_state,
791 &state_path,
792 "workspace_validated",
793 || validate_release_workspace(&root),
794 )?;
795
796 if !local {
797 run_phase(&mut release_state, &state_path, "remote_validated", || {
798 validate_remote_preflight(&rc, &release_branch, &requests)
799 })?;
800 }
801
802 let released = time::now()
803 .strftime("%Y-%m-%d")
804 .map_err(|e| origen::Error::new(&format!("Could not format release date: {}", e)))?
805 .to_string();
806 let mut touched = Vec::new();
807 for plan in &plans {
808 if plan.release_type != "current" {
809 touched.push(plan.version.source().clone());
810 touched.extend(plan.cargo_versions.iter().map(|v| v.source().clone()));
811 }
812 touched.push(ReleaseScribe::for_product(plan.product.release_product())?.history_file);
813 }
814 touched.extend(release_lock_paths(&root, &requests));
815 touched.extend(release_uv_lock_paths(&root)?);
816 if requests.iter().any(|r| r.product == Product::Origen) {
817 touched.push(
818 root.join("python/origen/origen/__bin__/bin")
819 .join(if cfg!(windows) {
820 "origen.exe"
821 } else {
822 "origen"
823 }),
824 );
825 }
826 run_phase(&mut release_state, &state_path, "prepared", || {
827 let backups = backup_files(&touched)?;
828 let result: Result<()> = (|| {
829 for mut plan in plans {
830 let scribe = ReleaseScribe::for_product(plan.product.release_product())?;
831 let anchor = format!(
832 "(release-{}-{})=",
833 plan.product.release_product().tag_prefix(),
834 plan.version.version().to_string().replace('.', "-")
835 );
836 if scribe.history_file.is_file()
837 && fs::read_to_string(&scribe.history_file)?.contains(&anchor)
838 {
839 bail!(
840 "Release history already contains {} {}",
841 plan.product.name(),
842 plan.version.version(),
843 )
844 }
845 if plan.release_type != "current" {
846 plan.version.write()?;
847 for cargo_version in &mut plan.cargo_versions {
848 cargo_version.write()?;
849 }
850 }
851 scribe.prepend_release(
852 plan.product.release_product(),
853 plan.version.version(),
854 &author,
855 ¬e,
856 &released,
857 )?;
858 }
859 update_release_locks(&root, &requests)?;
860 refresh_uv_locks(&root)?;
861 validate_prepared_artifacts(&root, &requests)?;
862 validate_prepared_documentation(&app.root)?;
863 Ok(())
864 })();
865 if let Err(error) = result {
866 restore_files(&backups)?;
867 run_checked(
868 Path::new("uv"),
869 &["sync", "--all-groups", "--no-editable"],
870 &root.join("python/origen"),
871 )?;
872 bail!(
873 "Release preparation failed and all file changes were rolled back: {}",
874 error
875 )
876 }
877 Ok(())
878 })?;
879 if local {
880 displayln!("Local release preparation complete; no commit, tag, push, publication, workflow, or website deployment was performed");
881 return Ok(());
882 }
883 let repo = github_repository.as_ref().ok_or_else(|| {
884 origen::Error::new("GitHub Actions provider requires a GitHub repository")
885 })?;
886 let commit_paths = release_paths(&root, &requests)?;
887 let mut effects = ProductionEffects {
888 rc: &rc,
889 repo,
890 release_branch: release_branch.clone(),
891 requests: requests.clone(),
892 paths: commit_paths,
893 workflow: release_workflow,
894 };
895 execute_remote_phases(&mut effects, &requests, &mut release_state, &state_path)?;
896 if wait {
897 monitor_release_workflow(repo, &mut release_state, &state_path, true)?;
898 }
899 displayln!("Release dispatched successfully");
900 Ok(())
901}
902
903fn validate_remote_preflight(
904 rc: &origen_metal::utils::revision_control::RevisionControl,
905 release_branch: &str,
906 requests: &[PublishRequest],
907) -> Result<()> {
908 if rc.current_branch()? != release_branch {
909 bail!("Releases must run from branch '{}'", release_branch)
910 }
911 let latest = rc.confirm_latest_ref(release_branch)?;
912 if !latest.0 {
913 bail!(
914 "Local HEAD {} does not match upstream {}",
915 latest.1[0],
916 latest.1[1]
917 )
918 }
919 for request in requests {
920 if rc.tag_exists(&request.tag)? {
921 bail!("Release tag '{}' already exists", request.tag)
922 }
923 let package = match request.product {
924 Product::Origen => "origen",
925 Product::Metal => "origen-metal",
926 };
927 if !is_package_version_available(package, &request.version)? {
928 bail!("{} {} already exists on PyPI", package, request.version)
929 }
930 if request.product == Product::Metal
931 && !is_crate_version_available("origen_metal", &request.version)?
932 {
933 bail!(
934 "origen_metal {} already exists on crates.io",
935 request.version
936 )
937 }
938 }
939 Ok(())
940}
941
942fn run_checked(program: &Path, args: &[&str], cwd: &Path) -> Result<()> {
943 let status = Command::new(program).args(args).current_dir(cwd).status()?;
944 if !status.success() {
945 bail!(
946 "Command failed in {}: {} {}",
947 cwd.display(),
948 program.display(),
949 args.join(" ")
950 )
951 }
952 Ok(())
953}
954
955fn run_checked_with_env(
956 program: &Path,
957 args: &[&str],
958 cwd: &Path,
959 envs: &[(&str, String)],
960) -> Result<()> {
961 let mut command = Command::new(program);
962 command.args(args).current_dir(cwd);
963 for (name, value) in envs {
964 command.env(name, value);
965 }
966 let status = command.status()?;
967 if !status.success() {
968 bail!(
969 "Command failed in {}: {} {}",
970 cwd.display(),
971 program.display(),
972 args.join(" ")
973 )
974 }
975 Ok(())
976}
977
978fn release_paths(root: &Path, requests: &[PublishRequest]) -> Result<Vec<PathBuf>> {
979 let mut paths = Vec::new();
980 for request in requests {
981 match request.product {
982 Product::Origen => paths.extend([
983 root.join("python/origen/pyproject.toml"),
984 root.join("rust/origen/Cargo.toml"),
985 root.join("rust/origen/cli/Cargo.toml"),
986 root.join("rust/pyapi/Cargo.toml"),
987 root.join("python/origen/doc/history"),
988 ]),
989 Product::Metal => paths.extend([
990 root.join("python/origen_metal/pyproject.toml"),
991 root.join("rust/origen_metal/Cargo.toml"),
992 root.join("rust/pyapi_metal/Cargo.toml"),
993 root.join("python/origen/doc/metal/history"),
994 ]),
995 }
996 }
997 paths.extend(release_lock_paths(root, requests));
998 paths.extend(release_uv_lock_paths(root)?);
999 paths.sort();
1000 paths.dedup();
1001 for path in &paths {
1002 if !path.is_file() {
1003 bail!("Expected release file '{}' does not exist", path.display())
1004 }
1005 }
1006 Ok(paths)
1007}
1008
1009fn release_uv_lock_paths(root: &Path) -> Result<Vec<PathBuf>> {
1010 fn collect(dir: &Path, locks: &mut Vec<PathBuf>) -> Result<()> {
1011 if !dir.is_dir() {
1012 return Ok(());
1013 }
1014 for entry in fs::read_dir(dir)? {
1015 let path = entry?.path();
1016 if path.is_dir() {
1017 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1018 if !name.starts_with('.') && name != "target" && name != "build" && name != "output"
1019 {
1020 collect(&path, locks)?;
1021 }
1022 } else if path.file_name().and_then(|n| n.to_str()) == Some("uv.lock") {
1023 locks.push(path);
1024 }
1025 }
1026 Ok(())
1027 }
1028 let mut locks = Vec::new();
1029 collect(&root.join("python"), &mut locks)?;
1030 collect(&root.join("test_apps"), &mut locks)?;
1031 locks.sort();
1032 Ok(locks)
1033}
1034
1035fn validate_uv_version() -> Result<()> {
1036 let output = Command::new("uv").arg("--version").output()?;
1037 if !output.status.success() {
1038 bail!("Could not query UV version")
1039 }
1040 let text = String::from_utf8(output.stdout)?;
1041 let version = text
1042 .split_whitespace()
1043 .nth(1)
1044 .ok_or_else(|| origen::Error::new("Could not parse UV version"))?;
1045 let found = semver::Version::parse(version).map_err(|e| {
1046 origen::Error::new(&format!("Could not parse UV version '{}': {}", version, e))
1047 })?;
1048 let minimum = semver::Version::parse("0.12.0").unwrap();
1049 if found < minimum {
1050 bail!(
1051 "UV {} is too old for release lock generation; install UV 0.12.0 or newer",
1052 found
1053 )
1054 }
1055 Ok(())
1056}
1057
1058fn refresh_uv_locks(root: &Path) -> Result<()> {
1059 for lock in release_uv_lock_paths(root)? {
1060 run_checked(Path::new("uv"), &["lock"], lock.parent().unwrap())?;
1061 }
1062 Ok(())
1063}
1064
1065fn release_lock_paths(root: &Path, requests: &[PublishRequest]) -> Vec<PathBuf> {
1066 let origen = requests.iter().any(|r| r.product == Product::Origen);
1067 let metal = requests.iter().any(|r| r.product == Product::Metal);
1068 let mut paths = Vec::new();
1069 if origen || metal {
1070 paths.extend([
1071 root.join("rust/origen/Cargo.lock"),
1072 root.join("rust/pyapi/Cargo.lock"),
1073 ]);
1074 }
1075 if metal {
1076 paths.extend([
1077 root.join("rust/origen_metal/Cargo.lock"),
1078 root.join("rust/pyapi_metal/Cargo.lock"),
1079 ]);
1080 }
1081 paths
1082}
1083
1084fn update_release_locks(root: &Path, requests: &[PublishRequest]) -> Result<()> {
1085 let mut updates = std::collections::HashMap::new();
1086 for request in requests {
1087 let mut version = Version::new_pep440(&request.version)?;
1088 version.convert_to_semver();
1089 let version = version.to_string();
1090 match request.product {
1091 Product::Origen => {
1092 updates.insert("origen", version.clone());
1093 updates.insert("origen_pyapi", version.clone());
1094 updates.insert("cli", version);
1095 }
1096 Product::Metal => {
1097 updates.insert("origen_metal", version.clone());
1098 updates.insert("origen-metal", version);
1099 }
1100 }
1101 }
1102 for path in release_lock_paths(root, requests) {
1103 let original = fs::read_to_string(&path)?;
1104 let mut package: Option<&str> = None;
1105 let mut changed = false;
1106 let mut rendered = Vec::new();
1107 for line in original.lines() {
1108 if line == "[[package]]" {
1109 package = None;
1110 } else if package.is_none() && line.starts_with("name = \"") {
1111 package = line
1112 .strip_prefix("name = \"")
1113 .and_then(|v| v.strip_suffix('"'));
1114 }
1115 if line.starts_with("version = \"") {
1116 if let Some(version) = package.and_then(|name| updates.get(name)) {
1117 rendered.push(format!("version = \"{}\"", version));
1118 changed |= line != rendered.last().unwrap();
1119 continue;
1120 }
1121 }
1122 rendered.push(line.to_string());
1123 }
1124 if changed {
1125 fs::write(&path, format!("{}\n", rendered.join("\n")))?;
1126 }
1127 }
1128 Ok(())
1129}
1130
1131fn validate_release_workspace(root: &Path) -> Result<()> {
1132 run_checked(
1133 Path::new("cargo"),
1134 &["test", "-p", "cli", "--locked"],
1135 &root.join("rust/origen"),
1136 )?;
1137 run_checked(
1138 Path::new("cargo"),
1139 &["test", "-p", "origen", "--lib", "--locked"],
1140 &root.join("rust/origen"),
1141 )?;
1142 run_checked(
1143 Path::new("cargo"),
1144 &["test", "-p", "origen_metal", "--locked"],
1145 &root.join("rust/origen"),
1146 )?;
1147 let executable = std::env::current_exe()?;
1148 let origen_root = root.join("python/origen");
1149 run_checked(
1150 &executable,
1151 &["develop_origen", "build", "--metal"],
1152 &origen_root,
1153 )?;
1154 run_checked(&executable, &["develop_origen", "build"], &origen_root)?;
1155 let debug_cli = root.join("rust/origen/target/debug");
1156 let python_path = [
1157 root.join("rust/pyapi/target"),
1158 root.join("python/origen_metal"),
1159 root.join("python/origen"),
1160 ]
1161 .iter()
1162 .map(|p| p.to_string_lossy().to_string())
1163 .collect::<Vec<String>>()
1164 .join(if cfg!(windows) { ";" } else { ":" });
1165 let path = format!(
1166 "{}{}{}",
1167 debug_cli.display(),
1168 if cfg!(windows) { ";" } else { ":" },
1169 std::env::var("PATH").unwrap_or_default()
1170 );
1171 let envs = [("PATH", path), ("PYTHONPATH", python_path)];
1172 for test_app in ["test_apps/python_app", "test_apps/python_no_app"] {
1173 let cwd = root.join(test_app);
1174 run_checked(
1175 Path::new("uv"),
1176 &["sync", "--all-groups", "--no-editable"],
1177 &cwd,
1178 )?;
1179 run_checked_with_env(
1180 Path::new("uv"),
1181 &["run", "--no-sync", "--no-editable", "pytest", "-q"],
1182 &cwd,
1183 &envs,
1184 )?;
1185 }
1186 Ok(())
1187}
1188
1189fn validate_prepared_documentation(app_root: &Path) -> Result<()> {
1190 let executable = std::env::current_exe()?;
1191 run_checked(
1192 &executable,
1193 &["web", "build", "--clean", "--as-release"],
1194 app_root,
1195 )
1196}
1197
1198fn validate_prepared_artifacts(root: &Path, requests: &[PublishRequest]) -> Result<()> {
1199 if requests.iter().any(|r| r.product == Product::Origen) {
1200 run_checked(
1201 Path::new("cargo"),
1202 &[
1203 "build",
1204 "--manifest-path",
1205 "rust/origen/cli/Cargo.toml",
1206 "--release",
1207 "--bin",
1208 "origen",
1209 ],
1210 root,
1211 )?;
1212 let executable = if cfg!(windows) {
1213 "origen.exe"
1214 } else {
1215 "origen"
1216 };
1217 let built_cli = root.join("rust/origen/target/release").join(executable);
1218 let packaged_cli = root
1219 .join("python/origen/origen/__bin__/bin")
1220 .join(executable);
1221 fs::copy(&built_cli, &packaged_cli).map_err(|e| {
1222 origen::Error::new(&format!(
1223 "Could not stage release CLI from {} to {}: {}",
1224 built_cli.display(),
1225 packaged_cli.display(),
1226 e
1227 ))
1228 })?;
1229 run_checked(
1230 Path::new("uv"),
1231 &["build", "--wheel"],
1232 &root.join("python/origen"),
1233 )?;
1234 }
1235 if requests.iter().any(|r| r.product == Product::Metal) {
1236 run_checked(
1237 Path::new("uv"),
1238 &["build", "--wheel"],
1239 &root.join("python/origen_metal"),
1240 )?;
1241 run_checked(
1242 Path::new("cargo"),
1243 &["publish", "--dry-run", "--locked", "--allow-dirty"],
1244 &root.join("rust/origen_metal"),
1245 )?;
1246 }
1247 run_checked(
1250 Path::new("uv"),
1251 &["sync", "--all-groups", "--no-editable"],
1252 &root.join("python/origen"),
1253 )?;
1254 Ok(())
1255}
1256
1257fn selected_products(args: &clap::ArgMatches, non_interactive: bool) -> Result<Vec<Product>> {
1258 if let Some(values) = args.get_many::<String>("product") {
1259 let mut products = Vec::new();
1260 for value in values {
1261 let product = Product::parse(value);
1262 if !products.contains(&product) {
1263 products.push(product);
1264 }
1265 }
1266 return Ok(products);
1267 }
1268 if non_interactive {
1269 bail!("--product is required in non-interactive mode")
1270 }
1271 let choices = ["Origen", "Origen Metal", "Both"];
1272 Ok(
1273 match Select::new()
1274 .with_prompt("Product to release")
1275 .items(&choices)
1276 .default(0)
1277 .interact()?
1278 {
1279 0 => vec![Product::Origen],
1280 1 => vec![Product::Metal],
1281 _ => vec![Product::Origen, Product::Metal],
1282 },
1283 )
1284}
1285
1286fn selected_release_type(
1287 args: &clap::ArgMatches,
1288 product: Product,
1289 non_interactive: bool,
1290) -> Result<String> {
1291 let specific = match product {
1292 Product::Origen => "origen-type",
1293 Product::Metal => "metal-type",
1294 };
1295 if let Some(value) = args
1296 .get_one::<String>(specific)
1297 .or_else(|| args.get_one::<String>("type"))
1298 {
1299 return Ok(value.to_string());
1300 }
1301 if non_interactive {
1302 bail!(
1303 "A release type is required for {} in non-interactive mode",
1304 product.name()
1305 )
1306 }
1307 let selected = Select::new()
1308 .with_prompt(format!("{} release type", product.name()))
1309 .items(&RELEASE_TYPES)
1310 .default(0)
1311 .interact()?;
1312 Ok(RELEASE_TYPES[selected].to_string())
1313}
1314
1315fn release_note(args: &clap::ArgMatches, non_interactive: bool, app_root: &Path) -> Result<String> {
1316 let note = if let Some(note) = args.get_one::<String>("note") {
1317 note.to_string()
1318 } else {
1319 let path = args
1320 .get_one::<String>("file")
1321 .map(PathBuf::from)
1322 .or_else(|| {
1323 let default = app_root.join("release_note.txt");
1324 if default.is_file() {
1325 Some(default)
1326 } else {
1327 None
1328 }
1329 });
1330 if let Some(path) = path {
1331 fs::read_to_string(path)?
1332 } else if non_interactive {
1333 bail!("A release note is required in non-interactive mode (--note or --file)")
1334 } else {
1335 Input::<String>::new()
1336 .with_prompt("Release note")
1337 .interact_text()?
1338 }
1339 };
1340 if note.trim().is_empty() {
1341 bail!("Release note cannot be empty")
1342 }
1343 Ok(note)
1344}
1345
1346fn to_release_type(value: &str) -> Result<ReleaseType> {
1347 Ok(match value {
1348 "development" => ReleaseType::Dev,
1349 "patch" => ReleaseType::Patch,
1350 "minor" => ReleaseType::Minor,
1351 "major" => ReleaseType::Major,
1352 _ => bail!("Unsupported release type '{}'", value),
1353 })
1354}
1355
1356fn normalize_repository(value: &str) -> String {
1357 value
1358 .trim()
1359 .trim_end_matches('/')
1360 .trim_end_matches(".git")
1361 .replace("git@github.com:", "https://github.com/")
1362 .to_lowercase()
1363}
1364
1365fn backup_files(paths: &[PathBuf]) -> Result<Vec<(PathBuf, Option<Vec<u8>>)>> {
1366 let mut seen = HashSet::new();
1367 let mut backups = Vec::new();
1368 for path in paths {
1369 if seen.insert(path.clone()) {
1370 backups.push((
1371 path.clone(),
1372 if path.exists() {
1373 Some(fs::read(path)?)
1374 } else {
1375 None
1376 },
1377 ));
1378 }
1379 }
1380 Ok(backups)
1381}
1382
1383fn restore_files(backups: &[(PathBuf, Option<Vec<u8>>)]) -> Result<()> {
1384 for (path, contents) in backups {
1385 match contents {
1386 Some(contents) => {
1387 if let Some(parent) = path.parent() {
1388 fs::create_dir_all(parent)?;
1389 }
1390 fs::write(path, contents)?;
1391 }
1392 None if path.exists() => fs::remove_file(path)?,
1393 None => {}
1394 }
1395 }
1396 Ok(())
1397}
1398
1399fn production_version(
1400 version: &origen_metal::utils::version::Version,
1401) -> Result<origen_metal::utils::version::Version> {
1402 let text = version.to_string();
1403 let pieces: Vec<&str> = text.split(|c| c == '.' || c == '-').collect();
1404 if pieces.len() < 3 {
1405 bail!("Could not derive a production version from '{}'", text)
1406 }
1407 origen_metal::utils::version::Version::new_pep440(&pieces[..3].join("."))
1408}
1409
1410fn proposed_version(current: &Version, release_type: &str) -> Result<Version> {
1411 if release_type == "current" {
1412 return Ok(current.clone());
1413 }
1414 if release_type == "production" {
1415 if !current.is_prerelease()? {
1416 bail!("{} is already a production version", current)
1417 }
1418 return production_version(current);
1419 }
1420 current.increment(&to_release_type(release_type)?)
1421}
1422
1423#[cfg(test)]
1424mod tests {
1425 use super::*;
1426 use origen_metal::utils::version::Version;
1427
1428 #[derive(Default)]
1429 struct FakeEffects {
1430 calls: Vec<String>,
1431 fail_on: Option<String>,
1432 }
1433
1434 impl FakeEffects {
1435 fn call(&mut self, value: String) -> Result<()> {
1436 self.calls.push(value.clone());
1437 if self.fail_on.as_ref() == Some(&value) {
1438 bail!("simulated failure at {}", value)
1439 }
1440 Ok(())
1441 }
1442 }
1443
1444 impl ReleaseEffects for FakeEffects {
1445 fn validate(&mut self) -> Result<()> {
1446 self.call("validate".to_string())
1447 }
1448 fn commit(&mut self) -> Result<()> {
1449 self.call("commit".to_string())
1450 }
1451 fn tag(&mut self, request: &PublishRequest) -> Result<()> {
1452 self.call(format!("tag:{}", request.tag))
1453 }
1454 fn dispatch_release(
1455 &mut self,
1456 release_id: &str,
1457 _requests: &[PublishRequest],
1458 ) -> Result<WorkflowRun> {
1459 self.call(format!("dispatch:{}", release_id))?;
1460 Ok(workflow_run())
1461 }
1462 }
1463
1464 fn requests() -> Vec<PublishRequest> {
1465 vec![
1466 PublishRequest {
1467 product: Product::Metal,
1468 tag: "origen-metal-v1.6.0".to_string(),
1469 version: "1.6.0".to_string(),
1470 workflow: Some("publish_metal.yml".to_string()),
1471 },
1472 PublishRequest {
1473 product: Product::Origen,
1474 tag: "origen-v2.0.0.dev9".to_string(),
1475 version: "2.0.0.dev9".to_string(),
1476 workflow: Some("publish.yml".to_string()),
1477 },
1478 ]
1479 }
1480
1481 fn workflow_run() -> WorkflowRun {
1482 WorkflowRun {
1483 id: 42,
1484 name: "Release O2 Products".to_string(),
1485 head_branch: "origen-metal-v1.6.0".to_string(),
1486 head_sha: "abc123".to_string(),
1487 status: "queued".to_string(),
1488 conclusion: None,
1489 url: "https://api.github.com/repos/Origen-SDK/o2/actions/runs/42".to_string(),
1490 html_url: "https://github.com/Origen-SDK/o2/actions/runs/42".to_string(),
1491 run_attempt: 1,
1492 path: ".github/workflows/release.yml".to_string(),
1493 event: "workflow_dispatch".to_string(),
1494 created_at: "2026-08-26T00:00:00Z".to_string(),
1495 updated_at: "2026-08-26T00:00:00Z".to_string(),
1496 run_started_at: "2026-08-26T00:00:00Z".to_string(),
1497 triggering_actor: origen::utility::github::Actor {
1498 login: "release-app".to_string(),
1499 id: 1,
1500 r#type: "Bot".to_string(),
1501 },
1502 cancel_url: "https://api.github.com/runs/42/cancel".to_string(),
1503 rerun_url: "https://api.github.com/runs/42/rerun".to_string(),
1504 }
1505 }
1506
1507 fn state(id: &str, requests: Vec<PublishRequest>) -> ReleaseState {
1508 ReleaseState::new(
1509 id.to_string(),
1510 requests,
1511 "https://github.com/example/repo.git".to_string(),
1512 "main".to_string(),
1513 "fake".to_string(),
1514 )
1515 }
1516
1517 #[test]
1518 fn repository_urls_are_compared_independent_of_git_transport() {
1519 assert_eq!(
1520 normalize_repository("git@github.com:Origen-SDK/o2.git"),
1521 normalize_repository("https://github.com/Origen-SDK/o2/"),
1522 );
1523 }
1524
1525 #[test]
1526 fn github_repository_is_derived_from_configured_remote() -> Result<()> {
1527 assert_eq!(
1528 GithubRepository::from_remote("git@github.com:Origen-SDK/o2.git")?,
1529 GithubRepository {
1530 owner: "origen-sdk".to_string(),
1531 name: "o2".to_string()
1532 },
1533 );
1534 assert!(GithubRepository::from_remote("https://example.com/O2/o2.git").is_err());
1535 Ok(())
1536 }
1537
1538 #[test]
1539 fn prerelease_versions_can_be_promoted() -> Result<()> {
1540 let version = Version::new_pep440("2.0.0.dev8")?;
1541 assert_eq!(production_version(&version)?.to_string(), "2.0.0");
1542 Ok(())
1543 }
1544
1545 #[test]
1546 fn all_release_types_calculate_expected_versions() -> Result<()> {
1547 let production = Version::new_pep440("1.5.0")?;
1548 assert_eq!(
1549 proposed_version(&production, "current")?.to_string(),
1550 "1.5.0"
1551 );
1552 assert_eq!(
1553 proposed_version(&production, "development")?.to_string(),
1554 "1.5.1.dev0"
1555 );
1556 assert_eq!(proposed_version(&production, "patch")?.to_string(), "1.5.1");
1557 assert_eq!(proposed_version(&production, "minor")?.to_string(), "1.6.0");
1558 assert_eq!(proposed_version(&production, "major")?.to_string(), "2.0.0");
1559 let development = Version::new_pep440("2.0.0.dev8")?;
1560 assert_eq!(
1561 proposed_version(&development, "development")?.to_string(),
1562 "2.0.0.dev9"
1563 );
1564 assert_eq!(
1565 proposed_version(&development, "production")?.to_string(),
1566 "2.0.0"
1567 );
1568 assert!(proposed_version(&production, "production").is_err());
1569 Ok(())
1570 }
1571
1572 #[test]
1573 fn release_lock_versions_are_updated_without_reformatting() -> Result<()> {
1574 let root = tempfile::tempdir()?;
1575 for relative in ["rust/origen/Cargo.lock", "rust/pyapi/Cargo.lock"] {
1576 let path = root.path().join(relative);
1577 fs::create_dir_all(path.parent().unwrap())?;
1578 fs::write(
1579 &path,
1580 "version = 3\n\n[[package]]\nname = \"origen\"\nversion = \"2.0.0-dev.8\"\n\n[[package]]\nname = \"unrelated\"\nversion = \"9.9.9\"\n",
1581 )?;
1582 }
1583 let request = PublishRequest {
1584 product: Product::Origen,
1585 tag: "origen-v2.0.0.dev9".to_string(),
1586 version: "2.0.0.dev9".to_string(),
1587 workflow: Some("publish.yml".to_string()),
1588 };
1589 update_release_locks(root.path(), &[request])?;
1590 let lock = fs::read_to_string(root.path().join("rust/origen/Cargo.lock"))?;
1591 assert!(lock.contains("name = \"origen\"\nversion = \"2.0.0-dev.9\""));
1592 assert!(lock.contains("name = \"unrelated\"\nversion = \"9.9.9\""));
1593 assert!(lock.starts_with("version = 3\n"));
1594 Ok(())
1595 }
1596
1597 #[test]
1598 fn uv_lock_collection_ignores_generated_and_hidden_directories() -> Result<()> {
1599 let root = tempfile::tempdir()?;
1600 for relative in [
1601 "python/origen/uv.lock",
1602 "test_apps/app/uv.lock",
1603 "test_apps/app/.venv/uv.lock",
1604 "python/origen/build/uv.lock",
1605 ] {
1606 let path = root.path().join(relative);
1607 fs::create_dir_all(path.parent().unwrap())?;
1608 fs::write(path, "")?;
1609 }
1610 assert_eq!(release_uv_lock_paths(root.path())?.len(), 2);
1611 Ok(())
1612 }
1613
1614 #[test]
1615 fn file_backups_restore_modified_and_new_files() -> Result<()> {
1616 let root = tempfile::tempdir()?;
1617 let existing = root.path().join("existing");
1618 let created = root.path().join("created");
1619 fs::write(&existing, "before")?;
1620 let backups = backup_files(&[existing.clone(), created.clone(), existing.clone()])?;
1621 fs::write(&existing, "after")?;
1622 fs::write(&created, "new")?;
1623 restore_files(&backups)?;
1624 assert_eq!(fs::read_to_string(existing)?, "before");
1625 assert!(!created.exists());
1626 Ok(())
1627 }
1628
1629 #[test]
1630 fn release_state_round_trips_and_phases_are_idempotent() -> Result<()> {
1631 let root = tempfile::tempdir()?;
1632 let path = root.path().join(".origen/releases/origen-v2.0.0.toml");
1633 let mut state = state("origen-v2.0.0", requests());
1634 state.complete("prepared");
1635 state.complete("prepared");
1636 state.save(&path)?;
1637 let loaded = ReleaseState::load(&path)?;
1638 assert_eq!(loaded.release_id, "origen-v2.0.0");
1639 assert_eq!(loaded.completed, vec!["prepared"]);
1640 assert!(loaded.is_complete("prepared"));
1641 assert!(!loaded.is_complete("published"));
1642 Ok(())
1643 }
1644
1645 #[test]
1646 fn completed_release_phases_are_not_repeated() -> Result<()> {
1647 let root = tempfile::tempdir()?;
1648 let path = root.path().join("state.toml");
1649 let mut state = state("fixture", requests());
1650 let mut calls = 0;
1651 run_phase(&mut state, &path, "published", || {
1652 calls += 1;
1653 Ok(())
1654 })?;
1655 run_phase(&mut state, &path, "published", || {
1656 calls += 1;
1657 Ok(())
1658 })?;
1659 assert_eq!(calls, 1);
1660 assert!(ReleaseState::load(&path)?.is_complete("published"));
1661 Ok(())
1662 }
1663
1664 #[test]
1665 fn remote_phases_tag_metal_first_and_dispatch_once() -> Result<()> {
1666 let root = tempfile::tempdir()?;
1667 let path = root.path().join("state.toml");
1668 let requests = requests();
1669 let mut state = state("combined", requests.clone());
1670 let mut effects = FakeEffects::default();
1671 execute_remote_phases(&mut effects, &requests, &mut state, &path)?;
1672 assert_eq!(
1673 effects.calls,
1674 vec![
1675 "validate",
1676 "commit",
1677 "tag:origen-metal-v1.6.0",
1678 "tag:origen-v2.0.0.dev9",
1679 "dispatch:combined",
1680 ]
1681 );
1682 let state = ReleaseState::load(&path)?;
1683 assert_eq!(state.workflow_run_id, Some(42));
1684 assert_eq!(
1685 state.workflow_url.as_deref(),
1686 Some("https://github.com/Origen-SDK/o2/actions/runs/42")
1687 );
1688 assert!(state.is_complete("dispatched"));
1689 Ok(())
1690 }
1691
1692 #[test]
1693 fn dispatched_release_does_not_repeat_remote_work() -> Result<()> {
1694 let root = tempfile::tempdir()?;
1695 let path = root.path().join("state.toml");
1696 let requests = requests();
1697 let mut state = state("combined", requests.clone());
1698 execute_remote_phases(&mut FakeEffects::default(), &requests, &mut state, &path)?;
1699 let mut state = ReleaseState::load(&path)?;
1700 let mut resumed = FakeEffects::default();
1701 execute_remote_phases(&mut resumed, &requests, &mut state, &path)?;
1702 assert!(resumed.calls.is_empty());
1703 Ok(())
1704 }
1705
1706 #[test]
1707 fn failed_dispatch_resumes_without_repeating_commit_or_tags() -> Result<()> {
1708 let root = tempfile::tempdir()?;
1709 let path = root.path().join("state.toml");
1710 let requests = requests();
1711 let mut state = state("combined", requests.clone());
1712 let mut first = FakeEffects {
1713 calls: Vec::new(),
1714 fail_on: Some("dispatch:combined".to_string()),
1715 };
1716 assert!(execute_remote_phases(&mut first, &requests, &mut state, &path).is_err());
1717 let mut state = ReleaseState::load(&path)?;
1718 let mut resumed = FakeEffects::default();
1719 execute_remote_phases(&mut resumed, &requests, &mut state, &path)?;
1720 assert_eq!(resumed.calls, vec!["dispatch:combined"]);
1721 Ok(())
1722 }
1723}