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