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