use crate::command::CommandRequest;
use crate::command::CommandRunner;
use crate::config::{Config, PublisherConfig, TargetConfig};
use crate::doctor::run_doctor;
use crate::domain::{ReleaseCandidate, ReleaseIntent, ReleasePlan, TargetPlan};
use crate::git::{GitRepository, RepositorySnapshot};
use crate::progress::{NoopProgressReporter, ProgressReporter, ReleasePhase};
use crate::publisher::{
GithubMavenPublisher, GithubReleasePublisher, OciRegistryPublisher, PublicationState,
Publisher, VerificationReport,
};
use crate::target::{
DockerArchiveTarget, MavenReactorTarget, OciImageTarget, PreparedDependencies, TargetAdapter,
};
use anyhow::{Context, Result, bail};
use chrono::NaiveDate;
use serde::Serialize;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
pub fn resolve_plan(
config: &Config,
snapshot: &RepositorySnapshot,
intent: ReleaseIntent,
today: NaiveDate,
requested_targets: &[String],
all_targets: bool,
runner: Arc<dyn CommandRunner>,
) -> Result<ReleasePlan> {
let existing = snapshot.tags.unique_release_for_commit(&snapshot.head)?;
let (tag, tag_already_sealed) = match (intent, existing) {
(_, Some(tag)) => (tag.name, true),
(ReleaseIntent::Existing, None) => {
bail!("current commit has no annotated remote calendar release tag; use `--release`")
}
(ReleaseIntent::New, None) => (snapshot.tags.next(today)?, false),
};
let selected = select_targets(config, requested_targets, all_targets)?;
let mut targets = Vec::with_capacity(selected.len());
for target in selected {
let candidate = ReleaseCandidate {
repository: config.repository.github.clone(),
commit: snapshot.head.clone(),
tag,
intent,
tag_already_sealed,
};
targets.push(resolve_target(
target,
&candidate,
&snapshot.root,
Arc::clone(&runner),
)?);
}
let mut artifact_owners = HashMap::new();
let mut oci_tag_owners = HashMap::new();
for target in &targets {
for artifact in &target.artifacts {
if let Some(owner) = artifact_owners.insert(artifact.clone(), target.name.as_str()) {
bail!(
"targets `{owner}` and `{}` claim the same artifact identity: {artifact:?}",
target.name
);
}
if let crate::domain::ArtifactIdentity::OciImage {
repository, tag, ..
} = artifact
&& let Some(owner) =
oci_tag_owners.insert((repository.as_str(), tag.as_str()), target.name.as_str())
{
bail!(
"targets `{owner}` and `{}` claim the same OCI tag `{repository}:{tag}`; platform does not create a separate tag namespace",
target.name
);
}
}
}
Ok(ReleasePlan {
tool_version: env!("CARGO_PKG_VERSION").to_owned(),
release: ReleaseCandidate {
repository: config.repository.github.clone(),
commit: snapshot.head.clone(),
tag,
intent,
tag_already_sealed,
},
targets,
})
}
fn select_targets<'a>(
config: &'a Config,
requested: &[String],
all: bool,
) -> Result<Vec<&'a TargetConfig>> {
if all && !requested.is_empty() {
bail!("`--all` and `--target` cannot be used together");
}
let by_name = config
.targets
.iter()
.map(|target| (target.name(), target))
.collect::<BTreeMap<_, _>>();
let roots = if all {
by_name.keys().map(|name| (*name).to_owned()).collect()
} else if requested.is_empty() {
let defaults = config
.targets
.iter()
.filter(|target| target.is_default())
.map(|target| target.name().to_owned())
.collect::<Vec<_>>();
if defaults.is_empty() {
bail!("no default release targets are configured; use `--target` or `--all`");
}
defaults
} else {
let mut seen = HashSet::new();
for name in requested {
if !seen.insert(name) {
bail!("target `{name}` was selected more than once");
}
if !by_name.contains_key(name.as_str()) {
bail!("unknown target `{name}`");
}
}
requested.to_vec()
};
let mut included = HashSet::new();
let mut pending = roots;
while let Some(name) = pending.pop() {
if !included.insert(name.clone()) {
continue;
}
let target = by_name
.get(name.as_str())
.with_context(|| format!("selected target disappeared: {name}"))?;
pending.extend(target.dependencies().iter().cloned());
}
Ok(config
.targets_in_dependency_order()?
.into_iter()
.filter(|target| included.contains(target.name()))
.collect())
}
fn resolve_target(
target: &TargetConfig,
release: &ReleaseCandidate,
root: &std::path::Path,
runner: Arc<dyn CommandRunner>,
) -> Result<TargetPlan> {
match target {
TargetConfig::DockerArchive { .. } => {
DockerArchiveTarget::new(root, target, runner)?.resolve(release)
}
TargetConfig::MavenReactor { .. } => {
MavenReactorTarget::new(root, target, runner)?.resolve(release)
}
TargetConfig::OciImage { .. } => {
OciImageTarget::new(root, target, runner)?.resolve(release)
}
}
}
pub fn expand(template: &str, version: &str, image: Option<&str>) -> Result<String> {
let mut expanded = template.replace("{version}", version);
if let Some(image) = image {
expanded = expanded.replace("{image}", image);
}
if expanded.contains('{') || expanded.contains('}') {
bail!("unsupported or unbalanced template placeholder in `{template}`");
}
Ok(expanded)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PublishOutcome {
pub tool_version: String,
pub commit: String,
pub target: String,
pub tag: String,
pub skipped: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub(crate) struct PublishTargetResult {
pub name: String,
pub publisher: String,
pub skipped: bool,
pub artifacts: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub(crate) struct PublishResult {
pub schema_version: u32,
pub tool_version: String,
pub repository: String,
pub tag: String,
pub commit: String,
pub targets: Vec<PublishTargetResult>,
}
impl PublishResult {
fn outcomes(&self) -> Vec<PublishOutcome> {
self.targets
.iter()
.map(|target| PublishOutcome {
tool_version: self.tool_version.clone(),
commit: self.commit.clone(),
target: target.name.clone(),
tag: self.tag.clone(),
skipped: target.skipped,
})
.collect()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PublishRequest {
pub intent: ReleaseIntent,
pub today: NaiveDate,
pub targets: Vec<String>,
pub all_targets: bool,
}
pub fn publish_release(
config: &Config,
root: &std::path::Path,
request: &PublishRequest,
runner: Arc<dyn CommandRunner>,
confirm: &mut dyn FnMut(&ReleasePlan) -> Result<bool>,
) -> Result<Vec<PublishOutcome>> {
Ok(publish_release_with_progress(
config,
root,
request,
runner,
confirm,
&NoopProgressReporter,
)?
.outcomes())
}
pub(crate) fn publish_release_with_progress(
config: &Config,
root: &std::path::Path,
request: &PublishRequest,
runner: Arc<dyn CommandRunner>,
confirm: &mut dyn FnMut(&ReleasePlan) -> Result<bool>,
progress: &dyn ProgressReporter,
) -> Result<PublishResult> {
progress.report(ReleasePhase::Resolve, "repository snapshot");
let repository = GitRepository::new(root, Arc::clone(&runner));
let snapshot = repository.snapshot(&config.repository.branch)?;
let plan = resolve_plan(
config,
&snapshot,
request.intent,
request.today,
&request.targets,
request.all_targets,
Arc::clone(&runner),
)?;
let refs_after_resolve = repository.ref_snapshot()?;
progress.report(ReleasePhase::Doctor, "tools and GitHub permissions");
let doctor = run_doctor(config, root, Arc::clone(&runner))?;
repository.ensure_refs_unchanged(&refs_after_resolve, "Doctor")?;
let mut runtime_targets = Vec::new();
let mut verified_targets = BTreeMap::new();
for target_plan in &plan.targets {
let target_config = config
.targets
.iter()
.find(|target| target.name() == target_plan.name)
.with_context(|| format!("target config disappeared: {}", target_plan.name))?;
let publisher = publisher_for(
config,
root,
target_config,
&doctor.actor,
doctor.token.clone(),
Arc::clone(&runner),
)?;
let publication_state = publisher.inspect(target_plan)?;
progress.report(
ReleasePhase::Inspect,
&format!(
"target {} is {}",
target_plan.name,
publication_state_name(&publication_state)
),
);
match publication_state {
PublicationState::Complete => {
if !plan.release.tag_already_sealed {
bail!(
"target `{}` is already published for candidate {}, but the release has no remote annotated tag; refusing to adopt orphan artifacts",
target_plan.name,
plan.release.tag
);
}
progress.report(
ReleasePhase::Verify,
&format!("existing target {}", target_plan.name),
);
let verification = publisher.verify_existing(target_plan)?;
run_remote_check(
target_config,
root,
&plan.release.tag.to_string(),
&doctor.actor,
&doctor.token,
&runner,
)?;
repository.revalidate_prepared(&plan.release, &config.repository.branch)?;
verified_targets.insert(
target_plan.name.clone(),
verified_target(target_plan, true, verification)?,
);
}
PublicationState::Invalid { reason } => {
bail!(
"target `{}` has invalid publication: {reason}",
target_plan.name
)
}
PublicationState::Partial { .. }
if matches!(target_config, TargetConfig::MavenReactor { .. }) =>
{
bail!(
"target `{}` has a partial Maven publication; automatic recovery is forbidden",
target_plan.name
)
}
PublicationState::Absent | PublicationState::Partial { .. } => {
runtime_targets.push(RuntimeTarget {
config: target_config.clone(),
plan: target_plan.clone(),
adapter: target_for(root, target_config, Arc::clone(&runner))?,
publisher,
});
}
}
}
repository.ensure_refs_unchanged(&refs_after_resolve, "Inspect/Verify")?;
if runtime_targets.is_empty() {
return publish_result(&plan, verified_targets);
}
let refs_before_prepare = repository.ref_snapshot()?;
progress.report(ReleasePhase::Prepare, "project preflight");
run_hook(
root,
&config.hooks.preflight,
&plan.release.tag.to_string(),
Arc::clone(&runner),
"project preflight",
)?;
let staging = tempfile::tempdir().context("failed to create release staging directory")?;
let mut manifests = Vec::new();
let mut prepared_by_target = BTreeMap::new();
for runtime in &runtime_targets {
progress.report(
ReleasePhase::Prepare,
&format!("target {}", runtime.plan.name),
);
let directory = staging.path().join(&runtime.plan.name);
let dependencies = runtime
.config
.dependencies()
.iter()
.map(|name| {
let manifest = prepared_by_target.get(name).cloned().with_context(|| {
format!(
"target `{}` dependency `{name}` was not prepared in topological order",
runtime.plan.name
)
})?;
Ok((name.clone(), manifest))
})
.collect::<Result<PreparedDependencies>>()?;
let manifest = runtime
.adapter
.prepare(&runtime.plan, &dependencies, &directory)?;
prepared_by_target.insert(runtime.plan.name.clone(), manifest.clone());
manifests.push(manifest);
}
repository.revalidate_prepared(&plan.release, &config.repository.branch)?;
repository.ensure_refs_unchanged(&refs_before_prepare, "Prepare")?;
for (runtime, manifest) in runtime_targets.iter().zip(&manifests) {
match runtime.publisher.inspect(&runtime.plan)? {
PublicationState::Complete => {
if !plan.release.tag_already_sealed {
bail!(
"target `{}` became complete during Prepare before the release was sealed; refusing to adopt orphan artifacts",
runtime.plan.name
);
}
runtime.publisher.verify(manifest)?;
}
PublicationState::Partial { .. }
if matches!(runtime.config, TargetConfig::MavenReactor { .. }) =>
{
bail!(
"target `{}` became a partial Maven publication during Prepare; automatic recovery is forbidden",
runtime.plan.name
);
}
PublicationState::Invalid { reason } => {
bail!(
"target `{}` became invalid during Prepare: {reason}",
runtime.plan.name
);
}
PublicationState::Absent | PublicationState::Partial { .. } => {}
}
}
if !plan.release.tag_already_sealed && !confirm(&plan)? {
bail!("release cancelled");
}
progress.report(ReleasePhase::Seal, &format!("tag {}", plan.release.tag));
let sealed = repository.seal(&plan.release, &config.repository.branch)?;
for manifest in &mut manifests {
manifest.release = sealed.release.clone();
}
let refs_after_seal = repository.ref_snapshot()?;
for (runtime, manifest) in runtime_targets.iter_mut().zip(&manifests) {
runtime.plan.release = sealed.release.clone();
repository.revalidate_prepared(&sealed.release, &config.repository.branch)?;
progress.report(
ReleasePhase::Publish,
&format!("target {}", runtime.plan.name),
);
let receipt = runtime.publisher.publish(manifest)?;
repository.revalidate_prepared(&sealed.release, &config.repository.branch)?;
progress.report(
ReleasePhase::Verify,
&format!("target {}", runtime.plan.name),
);
let verification = runtime.publisher.verify(manifest)?;
run_remote_check(
&runtime.config,
root,
&sealed.release.tag.to_string(),
&doctor.actor,
&doctor.token,
&runner,
)?;
repository.revalidate_prepared(&sealed.release, &config.repository.branch)?;
repository.ensure_refs_unchanged(&refs_after_seal, "Publish/Verify")?;
verified_targets.insert(
runtime.plan.name.clone(),
verified_target(&runtime.plan, receipt.skipped, verification)?,
);
}
publish_result(&plan, verified_targets)
}
fn verified_target(
plan: &TargetPlan,
skipped: bool,
verification: VerificationReport,
) -> Result<PublishTargetResult> {
if !verification.verified || verification.target != plan.name {
bail!(
"publisher returned an invalid VerificationReport for target `{}`",
plan.name
);
}
Ok(PublishTargetResult {
name: plan.name.clone(),
publisher: plan.publisher.clone(),
skipped,
artifacts: verification.artifacts,
})
}
fn publish_result(
plan: &ReleasePlan,
mut verified_targets: BTreeMap<String, PublishTargetResult>,
) -> Result<PublishResult> {
let targets = plan
.targets
.iter()
.map(|target| {
verified_targets
.remove(&target.name)
.with_context(|| format!("target `{}` has no final verification", target.name))
})
.collect::<Result<Vec<_>>>()?;
if !verified_targets.is_empty() {
bail!("final verification contains targets outside the ReleasePlan");
}
Ok(PublishResult {
schema_version: 1,
tool_version: plan.tool_version.clone(),
repository: plan.release.repository.clone(),
tag: plan.release.tag.to_string(),
commit: plan.release.commit.clone(),
targets,
})
}
fn publication_state_name(state: &PublicationState) -> &'static str {
match state {
PublicationState::Absent => "ABSENT",
PublicationState::Complete => "COMPLETE",
PublicationState::Partial { .. } => "PARTIAL",
PublicationState::Invalid { .. } => "INVALID",
}
}
struct RuntimeTarget {
config: TargetConfig,
plan: TargetPlan,
adapter: Box<dyn TargetAdapter>,
publisher: Box<dyn Publisher>,
}
fn target_for(
root: &std::path::Path,
config: &TargetConfig,
runner: Arc<dyn CommandRunner>,
) -> Result<Box<dyn TargetAdapter>> {
match config {
TargetConfig::DockerArchive { .. } => {
Ok(Box::new(DockerArchiveTarget::new(root, config, runner)?))
}
TargetConfig::MavenReactor { .. } => {
Ok(Box::new(MavenReactorTarget::new(root, config, runner)?))
}
TargetConfig::OciImage { .. } => Ok(Box::new(OciImageTarget::new(root, config, runner)?)),
}
}
fn publisher_for(
config: &Config,
root: &std::path::Path,
target: &TargetConfig,
actor: &str,
token: crate::doctor::Secret,
runner: Arc<dyn CommandRunner>,
) -> Result<Box<dyn Publisher>> {
match target {
TargetConfig::DockerArchive { publisher, .. } => {
let publisher_config = config
.publishers
.get(publisher)
.context("Docker archive publisher disappeared")?;
let PublisherConfig::GithubRelease { .. } = publisher_config else {
bail!("Docker archive target has an incompatible publisher");
};
Ok(Box::new(GithubReleasePublisher::new(
root,
&config.repository.github,
publisher,
publisher_config,
runner,
)?))
}
TargetConfig::MavenReactor {
publisher, wrapper, ..
} => {
let publisher_config = config
.publishers
.get(publisher)
.context("Maven publisher disappeared")?;
let PublisherConfig::GithubMaven { .. } = publisher_config else {
bail!("Maven reactor target has an incompatible publisher");
};
Ok(Box::new(GithubMavenPublisher::new(
root,
&config.repository.github,
publisher,
publisher_config,
wrapper,
actor,
token,
runner,
)?))
}
TargetConfig::OciImage { .. } => {
Ok(Box::new(OciRegistryPublisher::new(root, target, runner)?))
}
}
}
fn run_remote_check(
target: &TargetConfig,
root: &std::path::Path,
version: &str,
actor: &str,
token: &crate::doctor::Secret,
runner: &Arc<dyn CommandRunner>,
) -> Result<()> {
let TargetConfig::MavenReactor { remote_check, .. } = target else {
return Ok(());
};
if remote_check.is_empty() {
return Ok(());
}
let expanded = remote_check
.iter()
.map(|argument| expand(argument, version, None))
.collect::<Result<Vec<_>>>()?;
let (program, arguments) = expanded
.split_first()
.context("Maven remote check command must not be empty")?;
let mut request = CommandRequest::new(program, arguments.iter().cloned(), root);
request
.environment
.insert("GITHUB_ACTOR".to_owned(), actor.to_owned());
request
.environment
.insert("GITHUB_TOKEN".to_owned(), token.expose().to_owned());
runner
.execute(&request)?
.redact([token.expose()])
.require_success("Maven remote check")?;
Ok(())
}
fn run_hook(
root: &std::path::Path,
command: &[String],
version: &str,
runner: Arc<dyn CommandRunner>,
description: &str,
) -> Result<()> {
if command.is_empty() {
return Ok(());
}
let expanded = command
.iter()
.map(|argument| expand(argument, version, None))
.collect::<Result<Vec<_>>>()?;
let (program, arguments) = expanded
.split_first()
.context("hook command must not be empty")?;
let request = CommandRequest::new(program, arguments.iter().cloned(), root);
runner
.execute_inheriting_output(&request)?
.require_success(description)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{ReleaseCandidate, ReleaseIntent};
use crate::tag::CalendarTag;
#[test]
fn publish_result_uses_release_plan_target_order() {
let release = ReleaseCandidate {
repository: "computed-parameter/example".to_owned(),
commit: "1111111111111111111111111111111111111111".to_owned(),
tag: CalendarTag::parse("2026.08.26.1").unwrap(),
intent: ReleaseIntent::New,
tag_already_sealed: true,
};
let plan = ReleasePlan {
tool_version: "0.3.1".to_owned(),
release: release.clone(),
targets: ["base", "app"]
.into_iter()
.map(|name| TargetPlan {
name: name.to_owned(),
publisher: "oci_registry".to_owned(),
release: release.clone(),
artifacts: Vec::new(),
})
.collect(),
};
let verified = ["app", "base"]
.into_iter()
.map(|name| {
(
name.to_owned(),
PublishTargetResult {
name: name.to_owned(),
publisher: "oci_registry".to_owned(),
skipped: false,
artifacts: vec![format!("example/{name}@sha256:digest")],
},
)
})
.collect();
let result = publish_result(&plan, verified).unwrap();
assert_eq!(
result
.targets
.iter()
.map(|target| target.name.as_str())
.collect::<Vec<_>>(),
["base", "app"]
);
}
}