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}