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,
10    Publisher, VerificationReport,
11};
12use crate::target::{
13    DockerArchiveTarget, MavenReactorTarget, OciImageTarget, PreparedDependencies, TargetAdapter,
14};
15use anyhow::{Context, Result, bail};
16use chrono::NaiveDate;
17use serde::Serialize;
18use std::collections::{BTreeMap, HashMap, HashSet};
19use std::sync::Arc;
20
21pub fn resolve_plan(
22    config: &Config,
23    snapshot: &RepositorySnapshot,
24    intent: ReleaseIntent,
25    today: NaiveDate,
26    requested_targets: &[String],
27    all_targets: bool,
28    runner: Arc<dyn CommandRunner>,
29) -> Result<ReleasePlan> {
30    let existing = snapshot.tags.unique_release_for_commit(&snapshot.head)?;
31    let (tag, tag_already_sealed) = match (intent, existing) {
32        (_, Some(tag)) => (tag.name, true),
33        (ReleaseIntent::Existing, None) => {
34            bail!("current commit has no annotated remote calendar release tag; use `--release`")
35        }
36        (ReleaseIntent::New, None) => (snapshot.tags.next(today)?, false),
37    };
38    let selected = select_targets(config, requested_targets, all_targets)?;
39    let mut targets = Vec::with_capacity(selected.len());
40    for target in selected {
41        let candidate = ReleaseCandidate {
42            repository: config.repository.github.clone(),
43            commit: snapshot.head.clone(),
44            tag,
45            intent,
46            tag_already_sealed,
47        };
48        targets.push(resolve_target(
49            target,
50            &candidate,
51            &snapshot.root,
52            Arc::clone(&runner),
53        )?);
54    }
55    let mut artifact_owners = HashMap::new();
56    let mut oci_tag_owners = HashMap::new();
57    for target in &targets {
58        for artifact in &target.artifacts {
59            if let Some(owner) = artifact_owners.insert(artifact.clone(), target.name.as_str()) {
60                bail!(
61                    "targets `{owner}` and `{}` claim the same artifact identity: {artifact:?}",
62                    target.name
63                );
64            }
65            if let crate::domain::ArtifactIdentity::OciImage {
66                repository, tag, ..
67            } = artifact
68                && let Some(owner) =
69                    oci_tag_owners.insert((repository.as_str(), tag.as_str()), target.name.as_str())
70            {
71                bail!(
72                    "targets `{owner}` and `{}` claim the same OCI tag `{repository}:{tag}`; platform does not create a separate tag namespace",
73                    target.name
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, Serialize)]
188pub(crate) struct PublishTargetResult {
189    pub name: String,
190    pub publisher: String,
191    pub skipped: bool,
192    pub artifacts: Vec<String>,
193}
194
195#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
196pub(crate) struct PublishResult {
197    pub schema_version: u32,
198    pub tool_version: String,
199    pub repository: String,
200    pub tag: String,
201    pub commit: String,
202    pub targets: Vec<PublishTargetResult>,
203}
204
205impl PublishResult {
206    fn outcomes(&self) -> Vec<PublishOutcome> {
207        self.targets
208            .iter()
209            .map(|target| PublishOutcome {
210                tool_version: self.tool_version.clone(),
211                commit: self.commit.clone(),
212                target: target.name.clone(),
213                tag: self.tag.clone(),
214                skipped: target.skipped,
215            })
216            .collect()
217    }
218}
219
220#[derive(Clone, Debug, Eq, PartialEq)]
221pub struct PublishRequest {
222    pub intent: ReleaseIntent,
223    pub today: NaiveDate,
224    pub targets: Vec<String>,
225    pub all_targets: bool,
226}
227
228pub fn publish_release(
229    config: &Config,
230    root: &std::path::Path,
231    request: &PublishRequest,
232    runner: Arc<dyn CommandRunner>,
233    confirm: &mut dyn FnMut(&ReleasePlan) -> Result<bool>,
234) -> Result<Vec<PublishOutcome>> {
235    Ok(publish_release_with_progress(
236        config,
237        root,
238        request,
239        runner,
240        confirm,
241        &NoopProgressReporter,
242    )?
243    .outcomes())
244}
245
246pub(crate) fn publish_release_with_progress(
247    config: &Config,
248    root: &std::path::Path,
249    request: &PublishRequest,
250    runner: Arc<dyn CommandRunner>,
251    confirm: &mut dyn FnMut(&ReleasePlan) -> Result<bool>,
252    progress: &dyn ProgressReporter,
253) -> Result<PublishResult> {
254    progress.report(ReleasePhase::Resolve, "repository snapshot");
255    let repository = GitRepository::new(root, Arc::clone(&runner));
256    let snapshot = repository.snapshot(&config.repository.branch)?;
257    let plan = resolve_plan(
258        config,
259        &snapshot,
260        request.intent,
261        request.today,
262        &request.targets,
263        request.all_targets,
264        Arc::clone(&runner),
265    )?;
266    let refs_after_resolve = repository.ref_snapshot()?;
267    progress.report(ReleasePhase::Doctor, "tools and GitHub permissions");
268    let doctor = run_doctor(config, root, Arc::clone(&runner))?;
269    repository.ensure_refs_unchanged(&refs_after_resolve, "Doctor")?;
270    let mut runtime_targets = Vec::new();
271    let mut verified_targets = BTreeMap::new();
272    for target_plan in &plan.targets {
273        let target_config = config
274            .targets
275            .iter()
276            .find(|target| target.name() == target_plan.name)
277            .with_context(|| format!("target config disappeared: {}", target_plan.name))?;
278        let publisher = publisher_for(
279            config,
280            root,
281            target_config,
282            &doctor.actor,
283            doctor.token.clone(),
284            Arc::clone(&runner),
285        )?;
286        let publication_state = publisher.inspect(target_plan)?;
287        progress.report(
288            ReleasePhase::Inspect,
289            &format!(
290                "target {} is {}",
291                target_plan.name,
292                publication_state_name(&publication_state)
293            ),
294        );
295        match publication_state {
296            PublicationState::Complete => {
297                if !plan.release.tag_already_sealed {
298                    bail!(
299                        "target `{}` is already published for candidate {}, but the release has no remote annotated tag; refusing to adopt orphan artifacts",
300                        target_plan.name,
301                        plan.release.tag
302                    );
303                }
304                progress.report(
305                    ReleasePhase::Verify,
306                    &format!("existing target {}", target_plan.name),
307                );
308                let verification = publisher.verify_existing(target_plan)?;
309                run_remote_check(
310                    target_config,
311                    root,
312                    &plan.release.tag.to_string(),
313                    &doctor.actor,
314                    &doctor.token,
315                    &runner,
316                )?;
317                repository.revalidate_prepared(&plan.release, &config.repository.branch)?;
318                verified_targets.insert(
319                    target_plan.name.clone(),
320                    verified_target(target_plan, true, verification)?,
321                );
322            }
323            PublicationState::Invalid { reason } => {
324                bail!(
325                    "target `{}` has invalid publication: {reason}",
326                    target_plan.name
327                )
328            }
329            PublicationState::Partial { .. }
330                if matches!(target_config, TargetConfig::MavenReactor { .. }) =>
331            {
332                bail!(
333                    "target `{}` has a partial Maven publication; automatic recovery is forbidden",
334                    target_plan.name
335                )
336            }
337            PublicationState::Absent | PublicationState::Partial { .. } => {
338                runtime_targets.push(RuntimeTarget {
339                    config: target_config.clone(),
340                    plan: target_plan.clone(),
341                    adapter: target_for(root, target_config, Arc::clone(&runner))?,
342                    publisher,
343                });
344            }
345        }
346    }
347    repository.ensure_refs_unchanged(&refs_after_resolve, "Inspect/Verify")?;
348    if runtime_targets.is_empty() {
349        return publish_result(&plan, verified_targets);
350    }
351
352    let refs_before_prepare = repository.ref_snapshot()?;
353    progress.report(ReleasePhase::Prepare, "project preflight");
354    run_hook(
355        root,
356        &config.hooks.preflight,
357        &plan.release.tag.to_string(),
358        Arc::clone(&runner),
359        "project preflight",
360    )?;
361    let staging = tempfile::tempdir().context("failed to create release staging directory")?;
362    let mut manifests = Vec::new();
363    let mut prepared_by_target = BTreeMap::new();
364    for runtime in &runtime_targets {
365        progress.report(
366            ReleasePhase::Prepare,
367            &format!("target {}", runtime.plan.name),
368        );
369        let directory = staging.path().join(&runtime.plan.name);
370        let dependencies = runtime
371            .config
372            .dependencies()
373            .iter()
374            .map(|name| {
375                let manifest = prepared_by_target.get(name).cloned().with_context(|| {
376                    format!(
377                        "target `{}` dependency `{name}` was not prepared in topological order",
378                        runtime.plan.name
379                    )
380                })?;
381                Ok((name.clone(), manifest))
382            })
383            .collect::<Result<PreparedDependencies>>()?;
384        let manifest = runtime
385            .adapter
386            .prepare(&runtime.plan, &dependencies, &directory)?;
387        prepared_by_target.insert(runtime.plan.name.clone(), manifest.clone());
388        manifests.push(manifest);
389    }
390    repository.revalidate_prepared(&plan.release, &config.repository.branch)?;
391    repository.ensure_refs_unchanged(&refs_before_prepare, "Prepare")?;
392    for (runtime, manifest) in runtime_targets.iter().zip(&manifests) {
393        match runtime.publisher.inspect(&runtime.plan)? {
394            PublicationState::Complete => {
395                if !plan.release.tag_already_sealed {
396                    bail!(
397                        "target `{}` became complete during Prepare before the release was sealed; refusing to adopt orphan artifacts",
398                        runtime.plan.name
399                    );
400                }
401                runtime.publisher.verify(manifest)?;
402            }
403            PublicationState::Partial { .. }
404                if matches!(runtime.config, TargetConfig::MavenReactor { .. }) =>
405            {
406                bail!(
407                    "target `{}` became a partial Maven publication during Prepare; automatic recovery is forbidden",
408                    runtime.plan.name
409                );
410            }
411            PublicationState::Invalid { reason } => {
412                bail!(
413                    "target `{}` became invalid during Prepare: {reason}",
414                    runtime.plan.name
415                );
416            }
417            PublicationState::Absent | PublicationState::Partial { .. } => {}
418        }
419    }
420
421    if !plan.release.tag_already_sealed && !confirm(&plan)? {
422        bail!("release cancelled");
423    }
424    progress.report(ReleasePhase::Seal, &format!("tag {}", plan.release.tag));
425    let sealed = repository.seal(&plan.release, &config.repository.branch)?;
426    for manifest in &mut manifests {
427        manifest.release = sealed.release.clone();
428    }
429    let refs_after_seal = repository.ref_snapshot()?;
430    for (runtime, manifest) in runtime_targets.iter_mut().zip(&manifests) {
431        runtime.plan.release = sealed.release.clone();
432        repository.revalidate_prepared(&sealed.release, &config.repository.branch)?;
433        progress.report(
434            ReleasePhase::Publish,
435            &format!("target {}", runtime.plan.name),
436        );
437        let receipt = runtime.publisher.publish(manifest)?;
438        repository.revalidate_prepared(&sealed.release, &config.repository.branch)?;
439        progress.report(
440            ReleasePhase::Verify,
441            &format!("target {}", runtime.plan.name),
442        );
443        let verification = runtime.publisher.verify(manifest)?;
444        run_remote_check(
445            &runtime.config,
446            root,
447            &sealed.release.tag.to_string(),
448            &doctor.actor,
449            &doctor.token,
450            &runner,
451        )?;
452        repository.revalidate_prepared(&sealed.release, &config.repository.branch)?;
453        repository.ensure_refs_unchanged(&refs_after_seal, "Publish/Verify")?;
454        verified_targets.insert(
455            runtime.plan.name.clone(),
456            verified_target(&runtime.plan, receipt.skipped, verification)?,
457        );
458    }
459    publish_result(&plan, verified_targets)
460}
461
462fn verified_target(
463    plan: &TargetPlan,
464    skipped: bool,
465    verification: VerificationReport,
466) -> Result<PublishTargetResult> {
467    if !verification.verified || verification.target != plan.name {
468        bail!(
469            "publisher returned an invalid VerificationReport for target `{}`",
470            plan.name
471        );
472    }
473    Ok(PublishTargetResult {
474        name: plan.name.clone(),
475        publisher: plan.publisher.clone(),
476        skipped,
477        artifacts: verification.artifacts,
478    })
479}
480
481fn publish_result(
482    plan: &ReleasePlan,
483    mut verified_targets: BTreeMap<String, PublishTargetResult>,
484) -> Result<PublishResult> {
485    let targets = plan
486        .targets
487        .iter()
488        .map(|target| {
489            verified_targets
490                .remove(&target.name)
491                .with_context(|| format!("target `{}` has no final verification", target.name))
492        })
493        .collect::<Result<Vec<_>>>()?;
494    if !verified_targets.is_empty() {
495        bail!("final verification contains targets outside the ReleasePlan");
496    }
497    Ok(PublishResult {
498        schema_version: 1,
499        tool_version: plan.tool_version.clone(),
500        repository: plan.release.repository.clone(),
501        tag: plan.release.tag.to_string(),
502        commit: plan.release.commit.clone(),
503        targets,
504    })
505}
506
507fn publication_state_name(state: &PublicationState) -> &'static str {
508    match state {
509        PublicationState::Absent => "ABSENT",
510        PublicationState::Complete => "COMPLETE",
511        PublicationState::Partial { .. } => "PARTIAL",
512        PublicationState::Invalid { .. } => "INVALID",
513    }
514}
515
516struct RuntimeTarget {
517    config: TargetConfig,
518    plan: TargetPlan,
519    adapter: Box<dyn TargetAdapter>,
520    publisher: Box<dyn Publisher>,
521}
522
523fn target_for(
524    root: &std::path::Path,
525    config: &TargetConfig,
526    runner: Arc<dyn CommandRunner>,
527) -> Result<Box<dyn TargetAdapter>> {
528    match config {
529        TargetConfig::DockerArchive { .. } => {
530            Ok(Box::new(DockerArchiveTarget::new(root, config, runner)?))
531        }
532        TargetConfig::MavenReactor { .. } => {
533            Ok(Box::new(MavenReactorTarget::new(root, config, runner)?))
534        }
535        TargetConfig::OciImage { .. } => Ok(Box::new(OciImageTarget::new(root, config, runner)?)),
536    }
537}
538
539fn publisher_for(
540    config: &Config,
541    root: &std::path::Path,
542    target: &TargetConfig,
543    actor: &str,
544    token: crate::doctor::Secret,
545    runner: Arc<dyn CommandRunner>,
546) -> Result<Box<dyn Publisher>> {
547    match target {
548        TargetConfig::DockerArchive { publisher, .. } => {
549            let publisher_config = config
550                .publishers
551                .get(publisher)
552                .context("Docker archive publisher disappeared")?;
553            let PublisherConfig::GithubRelease { .. } = publisher_config else {
554                bail!("Docker archive target has an incompatible publisher");
555            };
556            Ok(Box::new(GithubReleasePublisher::new(
557                root,
558                &config.repository.github,
559                publisher,
560                publisher_config,
561                runner,
562            )?))
563        }
564        TargetConfig::MavenReactor {
565            publisher, wrapper, ..
566        } => {
567            let publisher_config = config
568                .publishers
569                .get(publisher)
570                .context("Maven publisher disappeared")?;
571            let PublisherConfig::GithubMaven { .. } = publisher_config else {
572                bail!("Maven reactor target has an incompatible publisher");
573            };
574            Ok(Box::new(GithubMavenPublisher::new(
575                root,
576                &config.repository.github,
577                publisher,
578                publisher_config,
579                wrapper,
580                actor,
581                token,
582                runner,
583            )?))
584        }
585        TargetConfig::OciImage { .. } => {
586            Ok(Box::new(OciRegistryPublisher::new(root, target, runner)?))
587        }
588    }
589}
590
591fn run_remote_check(
592    target: &TargetConfig,
593    root: &std::path::Path,
594    version: &str,
595    actor: &str,
596    token: &crate::doctor::Secret,
597    runner: &Arc<dyn CommandRunner>,
598) -> Result<()> {
599    let TargetConfig::MavenReactor { remote_check, .. } = target else {
600        return Ok(());
601    };
602    if remote_check.is_empty() {
603        return Ok(());
604    }
605    let expanded = remote_check
606        .iter()
607        .map(|argument| expand(argument, version, None))
608        .collect::<Result<Vec<_>>>()?;
609    let (program, arguments) = expanded
610        .split_first()
611        .context("Maven remote check command must not be empty")?;
612    let mut request = CommandRequest::new(program, arguments.iter().cloned(), root);
613    request
614        .environment
615        .insert("GITHUB_ACTOR".to_owned(), actor.to_owned());
616    request
617        .environment
618        .insert("GITHUB_TOKEN".to_owned(), token.expose().to_owned());
619    runner
620        .execute(&request)?
621        .redact([token.expose()])
622        .require_success("Maven remote check")?;
623    Ok(())
624}
625
626fn run_hook(
627    root: &std::path::Path,
628    command: &[String],
629    version: &str,
630    runner: Arc<dyn CommandRunner>,
631    description: &str,
632) -> Result<()> {
633    if command.is_empty() {
634        return Ok(());
635    }
636    let expanded = command
637        .iter()
638        .map(|argument| expand(argument, version, None))
639        .collect::<Result<Vec<_>>>()?;
640    let (program, arguments) = expanded
641        .split_first()
642        .context("hook command must not be empty")?;
643    let request = CommandRequest::new(program, arguments.iter().cloned(), root);
644    runner
645        .execute_inheriting_output(&request)?
646        .require_success(description)?;
647    Ok(())
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653    use crate::domain::{ReleaseCandidate, ReleaseIntent};
654    use crate::tag::CalendarTag;
655
656    #[test]
657    fn publish_result_uses_release_plan_target_order() {
658        let release = ReleaseCandidate {
659            repository: "computed-parameter/example".to_owned(),
660            commit: "1111111111111111111111111111111111111111".to_owned(),
661            tag: CalendarTag::parse("2026.08.26.1").unwrap(),
662            intent: ReleaseIntent::New,
663            tag_already_sealed: true,
664        };
665        let plan = ReleasePlan {
666            tool_version: "0.3.1".to_owned(),
667            release: release.clone(),
668            targets: ["base", "app"]
669                .into_iter()
670                .map(|name| TargetPlan {
671                    name: name.to_owned(),
672                    publisher: "oci_registry".to_owned(),
673                    release: release.clone(),
674                    artifacts: Vec::new(),
675                })
676                .collect(),
677        };
678        let verified = ["app", "base"]
679            .into_iter()
680            .map(|name| {
681                (
682                    name.to_owned(),
683                    PublishTargetResult {
684                        name: name.to_owned(),
685                        publisher: "oci_registry".to_owned(),
686                        skipped: false,
687                        artifacts: vec![format!("example/{name}@sha256:digest")],
688                    },
689                )
690            })
691            .collect();
692
693        let result = publish_result(&plan, verified).unwrap();
694
695        assert_eq!(
696            result
697                .targets
698                .iter()
699                .map(|target| target.name.as_str())
700                .collect::<Vec<_>>(),
701            ["base", "app"]
702        );
703    }
704}