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}