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}