Skip to main content

release_tool/
lifecycle.rs

1use crate::command::CommandRequest;
2use crate::command::CommandRunner;
3use crate::config::{Config, PublisherConfig, TargetConfig};
4use crate::doctor::run_doctor;
5use crate::domain::{ReleaseCandidate, ReleaseIntent, ReleasePlan, TargetPlan};
6use crate::git::{GitRepository, RepositorySnapshot};
7use crate::progress::{NoopProgressReporter, ProgressReporter, ReleasePhase};
8use crate::publisher::{
9    GithubMavenPublisher, GithubReleasePublisher, OciRegistryPublisher, PublicationState, Publisher,
10};
11use crate::target::{
12    DockerArchiveTarget, MavenReactorTarget, OciImageTarget, PreparedDependencies, TargetAdapter,
13};
14use anyhow::{Context, Result, bail};
15use chrono::NaiveDate;
16use std::collections::{BTreeMap, HashMap, HashSet};
17use std::sync::Arc;
18
19pub fn resolve_plan(
20    config: &Config,
21    snapshot: &RepositorySnapshot,
22    intent: ReleaseIntent,
23    today: NaiveDate,
24    requested_targets: &[String],
25    all_targets: bool,
26    runner: Arc<dyn CommandRunner>,
27) -> Result<ReleasePlan> {
28    let existing = snapshot.tags.unique_release_for_commit(&snapshot.head)?;
29    let (tag, tag_already_sealed) = match (intent, existing) {
30        (_, Some(tag)) => (tag.name, true),
31        (ReleaseIntent::Existing, None) => {
32            bail!("current commit has no annotated remote calendar release tag; use `--release`")
33        }
34        (ReleaseIntent::New, None) => (snapshot.tags.next(today)?, false),
35    };
36    let selected = select_targets(config, requested_targets, all_targets)?;
37    let mut targets = Vec::with_capacity(selected.len());
38    for target in selected {
39        let candidate = ReleaseCandidate {
40            repository: config.repository.github.clone(),
41            commit: snapshot.head.clone(),
42            tag,
43            intent,
44            tag_already_sealed,
45        };
46        targets.push(resolve_target(
47            target,
48            &candidate,
49            &snapshot.root,
50            Arc::clone(&runner),
51        )?);
52    }
53    let mut artifact_owners = HashMap::new();
54    let mut oci_tag_owners = HashMap::new();
55    for target in &targets {
56        for artifact in &target.artifacts {
57            if let Some(owner) = artifact_owners.insert(artifact.clone(), target.name.as_str()) {
58                bail!(
59                    "targets `{owner}` and `{}` claim the same artifact identity: {artifact:?}",
60                    target.name
61                );
62            }
63            if let crate::domain::ArtifactIdentity::OciImage {
64                repository, tag, ..
65            } = artifact
66            {
67                if let Some(owner) =
68                    oci_tag_owners.insert((repository.as_str(), tag.as_str()), target.name.as_str())
69                {
70                    bail!(
71                        "targets `{owner}` and `{}` claim the same OCI tag `{repository}:{tag}`; platform does not create a separate tag namespace",
72                        target.name
73                    );
74                }
75            }
76        }
77    }
78    Ok(ReleasePlan {
79        tool_version: env!("CARGO_PKG_VERSION").to_owned(),
80        release: ReleaseCandidate {
81            repository: config.repository.github.clone(),
82            commit: snapshot.head.clone(),
83            tag,
84            intent,
85            tag_already_sealed,
86        },
87        targets,
88    })
89}
90
91fn select_targets<'a>(
92    config: &'a Config,
93    requested: &[String],
94    all: bool,
95) -> Result<Vec<&'a TargetConfig>> {
96    if all && !requested.is_empty() {
97        bail!("`--all` and `--target` cannot be used together");
98    }
99    let by_name = config
100        .targets
101        .iter()
102        .map(|target| (target.name(), target))
103        .collect::<BTreeMap<_, _>>();
104    let roots = if all {
105        by_name.keys().map(|name| (*name).to_owned()).collect()
106    } else if requested.is_empty() {
107        let defaults = config
108            .targets
109            .iter()
110            .filter(|target| target.is_default())
111            .map(|target| target.name().to_owned())
112            .collect::<Vec<_>>();
113        if defaults.is_empty() {
114            bail!("no default release targets are configured; use `--target` or `--all`");
115        }
116        defaults
117    } else {
118        let mut seen = HashSet::new();
119        for name in requested {
120            if !seen.insert(name) {
121                bail!("target `{name}` was selected more than once");
122            }
123            if !by_name.contains_key(name.as_str()) {
124                bail!("unknown target `{name}`");
125            }
126        }
127        requested.to_vec()
128    };
129
130    let mut included = HashSet::new();
131    let mut pending = roots;
132    while let Some(name) = pending.pop() {
133        if !included.insert(name.clone()) {
134            continue;
135        }
136        let target = by_name
137            .get(name.as_str())
138            .with_context(|| format!("selected target disappeared: {name}"))?;
139        pending.extend(target.dependencies().iter().cloned());
140    }
141    Ok(config
142        .targets_in_dependency_order()?
143        .into_iter()
144        .filter(|target| included.contains(target.name()))
145        .collect())
146}
147
148fn resolve_target(
149    target: &TargetConfig,
150    release: &ReleaseCandidate,
151    root: &std::path::Path,
152    runner: Arc<dyn CommandRunner>,
153) -> Result<TargetPlan> {
154    match target {
155        TargetConfig::DockerArchive { .. } => {
156            DockerArchiveTarget::new(root, target, runner)?.resolve(release)
157        }
158        TargetConfig::MavenReactor { .. } => {
159            MavenReactorTarget::new(root, target, runner)?.resolve(release)
160        }
161        TargetConfig::OciImage { .. } => {
162            OciImageTarget::new(root, target, runner)?.resolve(release)
163        }
164    }
165}
166
167pub fn expand(template: &str, version: &str, image: Option<&str>) -> Result<String> {
168    let mut expanded = template.replace("{version}", version);
169    if let Some(image) = image {
170        expanded = expanded.replace("{image}", image);
171    }
172    if expanded.contains('{') || expanded.contains('}') {
173        bail!("unsupported or unbalanced template placeholder in `{template}`");
174    }
175    Ok(expanded)
176}
177
178#[derive(Clone, Debug, Eq, PartialEq)]
179pub struct PublishOutcome {
180    pub tool_version: String,
181    pub commit: String,
182    pub target: String,
183    pub tag: String,
184    pub skipped: bool,
185}
186
187#[derive(Clone, Debug, Eq, PartialEq)]
188pub struct PublishRequest {
189    pub intent: ReleaseIntent,
190    pub today: NaiveDate,
191    pub targets: Vec<String>,
192    pub all_targets: bool,
193}
194
195pub fn publish_release(
196    config: &Config,
197    root: &std::path::Path,
198    request: &PublishRequest,
199    runner: Arc<dyn CommandRunner>,
200    confirm: &mut dyn FnMut(&ReleasePlan) -> Result<bool>,
201) -> Result<Vec<PublishOutcome>> {
202    publish_release_with_progress(
203        config,
204        root,
205        request,
206        runner,
207        confirm,
208        &NoopProgressReporter,
209    )
210}
211
212pub(crate) fn publish_release_with_progress(
213    config: &Config,
214    root: &std::path::Path,
215    request: &PublishRequest,
216    runner: Arc<dyn CommandRunner>,
217    confirm: &mut dyn FnMut(&ReleasePlan) -> Result<bool>,
218    progress: &dyn ProgressReporter,
219) -> Result<Vec<PublishOutcome>> {
220    progress.report(ReleasePhase::Resolve, "repository snapshot");
221    let repository = GitRepository::new(root, Arc::clone(&runner));
222    let snapshot = repository.snapshot(&config.repository.branch)?;
223    let plan = resolve_plan(
224        config,
225        &snapshot,
226        request.intent,
227        request.today,
228        &request.targets,
229        request.all_targets,
230        Arc::clone(&runner),
231    )?;
232    let refs_after_resolve = repository.ref_snapshot()?;
233    progress.report(ReleasePhase::Doctor, "tools and GitHub permissions");
234    let doctor = run_doctor(config, root, Arc::clone(&runner))?;
235    repository.ensure_refs_unchanged(&refs_after_resolve, "Doctor")?;
236    let mut runtime_targets = Vec::new();
237    let mut outcomes = Vec::new();
238    for target_plan in &plan.targets {
239        let target_config = config
240            .targets
241            .iter()
242            .find(|target| target.name() == target_plan.name)
243            .with_context(|| format!("target config disappeared: {}", target_plan.name))?;
244        let publisher = publisher_for(
245            config,
246            root,
247            target_config,
248            &doctor.actor,
249            doctor.token.clone(),
250            Arc::clone(&runner),
251        )?;
252        let publication_state = publisher.inspect(target_plan)?;
253        progress.report(
254            ReleasePhase::Inspect,
255            &format!(
256                "target {} is {}",
257                target_plan.name,
258                publication_state_name(&publication_state)
259            ),
260        );
261        match publication_state {
262            PublicationState::Complete => {
263                if !plan.release.tag_already_sealed {
264                    bail!(
265                        "target `{}` is already published for candidate {}, but the release has no remote annotated tag; refusing to adopt orphan artifacts",
266                        target_plan.name,
267                        plan.release.tag
268                    );
269                }
270                progress.report(
271                    ReleasePhase::Verify,
272                    &format!("existing target {}", target_plan.name),
273                );
274                publisher.verify_existing(target_plan)?;
275                run_remote_check(
276                    target_config,
277                    root,
278                    &plan.release.tag.to_string(),
279                    &doctor.actor,
280                    &doctor.token,
281                    &runner,
282                )?;
283                repository.revalidate_prepared(&plan.release, &config.repository.branch)?;
284                outcomes.push(PublishOutcome {
285                    tool_version: plan.tool_version.clone(),
286                    commit: plan.release.commit.clone(),
287                    target: target_plan.name.clone(),
288                    tag: plan.release.tag.to_string(),
289                    skipped: true,
290                });
291            }
292            PublicationState::Invalid { reason } => {
293                bail!(
294                    "target `{}` has invalid publication: {reason}",
295                    target_plan.name
296                )
297            }
298            PublicationState::Partial { .. }
299                if matches!(target_config, TargetConfig::MavenReactor { .. }) =>
300            {
301                bail!(
302                    "target `{}` has a partial Maven publication; automatic recovery is forbidden",
303                    target_plan.name
304                )
305            }
306            PublicationState::Absent | PublicationState::Partial { .. } => {
307                runtime_targets.push(RuntimeTarget {
308                    config: target_config.clone(),
309                    plan: target_plan.clone(),
310                    adapter: target_for(root, target_config, Arc::clone(&runner))?,
311                    publisher,
312                });
313            }
314        }
315    }
316    repository.ensure_refs_unchanged(&refs_after_resolve, "Inspect/Verify")?;
317    if runtime_targets.is_empty() {
318        return Ok(outcomes);
319    }
320
321    let refs_before_prepare = repository.ref_snapshot()?;
322    if !config.hooks.preflight.is_empty() {
323        progress.report(ReleasePhase::Prepare, "project preflight");
324        run_hook(
325            root,
326            &config.hooks.preflight,
327            &plan.release.tag.to_string(),
328            Arc::clone(&runner),
329            "project preflight",
330        )?;
331    }
332    let staging = tempfile::tempdir().context("failed to create release staging directory")?;
333    let mut manifests = Vec::new();
334    let mut prepared_by_target = BTreeMap::new();
335    for runtime in &runtime_targets {
336        progress.report(
337            ReleasePhase::Prepare,
338            &format!("target {}", runtime.plan.name),
339        );
340        let directory = staging.path().join(&runtime.plan.name);
341        let dependencies = runtime
342            .config
343            .dependencies()
344            .iter()
345            .map(|name| {
346                let manifest = prepared_by_target.get(name).cloned().with_context(|| {
347                    format!(
348                        "target `{}` dependency `{name}` was not prepared in topological order",
349                        runtime.plan.name
350                    )
351                })?;
352                Ok((name.clone(), manifest))
353            })
354            .collect::<Result<PreparedDependencies>>()?;
355        let manifest = runtime
356            .adapter
357            .prepare(&runtime.plan, &dependencies, &directory)?;
358        prepared_by_target.insert(runtime.plan.name.clone(), manifest.clone());
359        manifests.push(manifest);
360    }
361    repository.revalidate_prepared(&plan.release, &config.repository.branch)?;
362    repository.ensure_refs_unchanged(&refs_before_prepare, "Prepare")?;
363    for (runtime, manifest) in runtime_targets.iter().zip(&manifests) {
364        match runtime.publisher.inspect(&runtime.plan)? {
365            PublicationState::Complete => {
366                if !plan.release.tag_already_sealed {
367                    bail!(
368                        "target `{}` became complete during Prepare before the release was sealed; refusing to adopt orphan artifacts",
369                        runtime.plan.name
370                    );
371                }
372                runtime.publisher.verify(manifest)?;
373            }
374            PublicationState::Partial { .. }
375                if matches!(runtime.config, TargetConfig::MavenReactor { .. }) =>
376            {
377                bail!(
378                    "target `{}` became a partial Maven publication during Prepare; automatic recovery is forbidden",
379                    runtime.plan.name
380                );
381            }
382            PublicationState::Invalid { reason } => {
383                bail!(
384                    "target `{}` became invalid during Prepare: {reason}",
385                    runtime.plan.name
386                );
387            }
388            PublicationState::Absent | PublicationState::Partial { .. } => {}
389        }
390    }
391
392    if !plan.release.tag_already_sealed && !confirm(&plan)? {
393        bail!("release cancelled");
394    }
395    progress.report(ReleasePhase::Seal, &format!("tag {}", plan.release.tag));
396    let sealed = repository.seal(&plan.release, &config.repository.branch)?;
397    for manifest in &mut manifests {
398        manifest.release = sealed.release.clone();
399    }
400    let refs_after_seal = repository.ref_snapshot()?;
401    for (runtime, manifest) in runtime_targets.iter_mut().zip(&manifests) {
402        runtime.plan.release = sealed.release.clone();
403        repository.revalidate_prepared(&sealed.release, &config.repository.branch)?;
404        progress.report(
405            ReleasePhase::Publish,
406            &format!("target {}", runtime.plan.name),
407        );
408        let receipt = runtime.publisher.publish(manifest)?;
409        repository.revalidate_prepared(&sealed.release, &config.repository.branch)?;
410        progress.report(
411            ReleasePhase::Verify,
412            &format!("target {}", runtime.plan.name),
413        );
414        runtime.publisher.verify(manifest)?;
415        run_remote_check(
416            &runtime.config,
417            root,
418            &sealed.release.tag.to_string(),
419            &doctor.actor,
420            &doctor.token,
421            &runner,
422        )?;
423        repository.revalidate_prepared(&sealed.release, &config.repository.branch)?;
424        repository.ensure_refs_unchanged(&refs_after_seal, "Publish/Verify")?;
425        outcomes.push(PublishOutcome {
426            tool_version: receipt.tool_version,
427            commit: receipt.commit,
428            target: receipt.target,
429            tag: receipt.tag,
430            skipped: receipt.skipped,
431        });
432    }
433    Ok(outcomes)
434}
435
436fn publication_state_name(state: &PublicationState) -> &'static str {
437    match state {
438        PublicationState::Absent => "ABSENT",
439        PublicationState::Complete => "COMPLETE",
440        PublicationState::Partial { .. } => "PARTIAL",
441        PublicationState::Invalid { .. } => "INVALID",
442    }
443}
444
445struct RuntimeTarget {
446    config: TargetConfig,
447    plan: TargetPlan,
448    adapter: Box<dyn TargetAdapter>,
449    publisher: Box<dyn Publisher>,
450}
451
452fn target_for(
453    root: &std::path::Path,
454    config: &TargetConfig,
455    runner: Arc<dyn CommandRunner>,
456) -> Result<Box<dyn TargetAdapter>> {
457    match config {
458        TargetConfig::DockerArchive { .. } => {
459            Ok(Box::new(DockerArchiveTarget::new(root, config, runner)?))
460        }
461        TargetConfig::MavenReactor { .. } => {
462            Ok(Box::new(MavenReactorTarget::new(root, config, runner)?))
463        }
464        TargetConfig::OciImage { .. } => Ok(Box::new(OciImageTarget::new(root, config, runner)?)),
465    }
466}
467
468fn publisher_for(
469    config: &Config,
470    root: &std::path::Path,
471    target: &TargetConfig,
472    actor: &str,
473    token: crate::doctor::Secret,
474    runner: Arc<dyn CommandRunner>,
475) -> Result<Box<dyn Publisher>> {
476    match target {
477        TargetConfig::DockerArchive { publisher, .. } => {
478            let publisher_config = config
479                .publishers
480                .get(publisher)
481                .context("Docker archive publisher disappeared")?;
482            let PublisherConfig::GithubRelease { .. } = publisher_config else {
483                bail!("Docker archive target has an incompatible publisher");
484            };
485            Ok(Box::new(GithubReleasePublisher::new(
486                root,
487                &config.repository.github,
488                publisher,
489                publisher_config,
490                runner,
491            )?))
492        }
493        TargetConfig::MavenReactor {
494            publisher, wrapper, ..
495        } => {
496            let publisher_config = config
497                .publishers
498                .get(publisher)
499                .context("Maven publisher disappeared")?;
500            let PublisherConfig::GithubMaven { .. } = publisher_config else {
501                bail!("Maven reactor target has an incompatible publisher");
502            };
503            Ok(Box::new(GithubMavenPublisher::new(
504                root,
505                &config.repository.github,
506                publisher,
507                publisher_config,
508                wrapper,
509                actor,
510                token,
511                runner,
512            )?))
513        }
514        TargetConfig::OciImage { .. } => {
515            Ok(Box::new(OciRegistryPublisher::new(root, target, runner)?))
516        }
517    }
518}
519
520fn run_remote_check(
521    target: &TargetConfig,
522    root: &std::path::Path,
523    version: &str,
524    actor: &str,
525    token: &crate::doctor::Secret,
526    runner: &Arc<dyn CommandRunner>,
527) -> Result<()> {
528    let TargetConfig::MavenReactor { remote_check, .. } = target else {
529        return Ok(());
530    };
531    if remote_check.is_empty() {
532        return Ok(());
533    }
534    let expanded = remote_check
535        .iter()
536        .map(|argument| expand(argument, version, None))
537        .collect::<Result<Vec<_>>>()?;
538    let (program, arguments) = expanded
539        .split_first()
540        .context("Maven remote check command must not be empty")?;
541    let mut request = CommandRequest::new(program, arguments.iter().cloned(), root);
542    request
543        .environment
544        .insert("GITHUB_ACTOR".to_owned(), actor.to_owned());
545    request
546        .environment
547        .insert("GITHUB_TOKEN".to_owned(), token.expose().to_owned());
548    runner
549        .execute(&request)?
550        .redact([token.expose()])
551        .require_success("Maven remote check")?;
552    Ok(())
553}
554
555fn run_hook(
556    root: &std::path::Path,
557    command: &[String],
558    version: &str,
559    runner: Arc<dyn CommandRunner>,
560    description: &str,
561) -> Result<()> {
562    if command.is_empty() {
563        return Ok(());
564    }
565    let expanded = command
566        .iter()
567        .map(|argument| expand(argument, version, None))
568        .collect::<Result<Vec<_>>>()?;
569    let (program, arguments) = expanded
570        .split_first()
571        .context("hook command must not be empty")?;
572    let request = CommandRequest::new(program, arguments.iter().cloned(), root);
573    runner
574        .execute_inheriting_output(&request)?
575        .require_success(description)?;
576    Ok(())
577}