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