origen/commands/
rc.rs

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    // The O2 repository root is a development workspace rather than an Origen
408    // application, while the core application configuration lives under
409    // python/origen. Make the repository root the stable maintainer entry point
410    // by re-entering the command from that application directory. A fresh
411    // process is required because application discovery occurs during CLI boot.
412    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                    &note,
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    // Refresh the environment used by the documentation command so imported
1260    // package metadata and native modules match the proposed release versions.
1261    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}