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