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}