use crate::ports::Tagger;
use crate::protocol::journal::{
EventKind, JournalEvent, Phase, PhaseOutcome, PublishReceipt as JournalReceipt, RunState,
JOURNAL_SCHEMA_VERSION,
};
use crate::protocol::plan::ReleasePlan;
use crate::protocol::release::PublishReceipt as AdapterReceipt;
use super::adapters::{
resolve, AdapterTarget, EcosystemAdapter, EffectCtx, ReleaseAdapter, ReleaseArtifacts,
SourceTarball,
};
use super::journal::Journal;
use crate::contract::schema::{Adapter, Ecosystem, Target};
pub trait ProgressSink {
fn event(&mut self, event: &JournalEvent);
}
pub struct NullSink;
impl ProgressSink for NullSink {
fn event(&mut self, _event: &JournalEvent) {}
}
#[derive(Debug)]
pub enum CutError {
PhaseFailed {
phase: Phase,
target: Option<String>,
message: String,
},
Journal(std::io::Error),
Plan(String),
}
impl std::fmt::Display for CutError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PhaseFailed {
phase,
target,
message,
} => match target {
Some(t) => write!(
f,
"{}-phase failed on target `{t}`: {message}",
phase.as_str()
),
None => write!(f, "{}-phase failed: {message}", phase.as_str()),
},
Self::Journal(e) => write!(f, "could not write the release journal: {e}"),
Self::Plan(m) => write!(f, "the sealed plan is not executable: {m}"),
}
}
}
impl std::error::Error for CutError {}
struct TargetPlan {
id: String,
adapter: EcosystemAdapter,
input: AdapterTarget,
}
pub fn execute(
journal: &mut Journal<'_>,
plan: &ReleasePlan,
ctx: &EffectCtx<'_>,
tagger: &dyn Tagger,
sink: &mut dyn ProgressSink,
) -> Result<(), CutError> {
let targets = resolve_target_plans(plan)?;
reversible_phase(journal, sink, ctx, Phase::DryRun, &targets, None)?;
let mut assets = Vec::new();
reversible_phase(
journal,
sink,
ctx,
Phase::Build,
&targets,
Some(&mut assets),
)?;
let repo_slug = resolve_repo_slug(ctx, &targets);
let source_tarball = repo_slug
.as_deref()
.and_then(|slug| source_tarball(slug, plan, &targets));
let artifacts = ReleaseArtifacts {
assets,
source_tarball,
repo_slug,
};
publish_phase(journal, sink, &ctx.with_artifacts(&artifacts), &targets)?;
tag_phase(journal, sink, tagger, plan)?;
Ok(())
}
pub fn validate_plan(plan: &ReleasePlan) -> Result<(), CutError> {
resolve_target_plans(plan).map(|_| ())
}
fn resolve_target_plans(plan: &ReleasePlan) -> Result<Vec<TargetPlan>, CutError> {
let mut out = Vec::with_capacity(plan.targets.len());
let mut seen: Vec<String> = Vec::new();
for t in &plan.targets {
let package = t.package.clone().ok_or_else(|| {
CutError::Plan(format!(
"target `{}` has no resolved package name — pin an explicit `package` \
in OSS-RELEASE.md and re-plan",
t.ecosystem.as_str()
))
})?;
let id = target_id(t.ecosystem);
if seen.contains(&id) {
return Err(CutError::Plan(format!(
"two targets resolve to the same journal id `{id}`; multiple targets in one \
ecosystem are not supported by a single cut"
)));
}
seen.push(id.clone());
let input = AdapterTarget {
target: Target {
ecosystem: t.ecosystem,
package: Some(package.clone()),
registry: t.registry,
adapter: t.adapter,
},
package,
version: plan.version.clone(),
};
out.push(TargetPlan {
id,
adapter: resolve(t.adapter),
input,
});
}
Ok(out)
}
fn target_id(ecosystem: Ecosystem) -> String {
ecosystem.as_str().to_string()
}
fn needs_github_slug(targets: &[TargetPlan]) -> bool {
targets.iter().any(|tp| {
matches!(
tp.input.target.adapter,
Adapter::Manual | Adapter::HomebrewTap | Adapter::HomebrewCore
)
})
}
fn resolve_repo_slug(ctx: &EffectCtx<'_>, targets: &[TargetPlan]) -> Option<String> {
if !needs_github_slug(targets) {
return None;
}
let out = ctx
.runner
.run("git", &["remote", "get-url", "origin"], ctx.repo_root)
.ok()?;
if out.status != Some(0) {
return None;
}
crate::vcs::parse_github_slug(out.stdout.trim())
}
fn source_tarball(slug: &str, plan: &ReleasePlan, targets: &[TargetPlan]) -> Option<SourceTarball> {
let needed = targets.iter().any(|tp| {
matches!(
tp.input.target.adapter,
Adapter::HomebrewTap | Adapter::HomebrewCore
)
});
if !needed {
return None;
}
let tag = format!("v{}", plan.version);
Some(SourceTarball {
url: format!("https://github.com/{slug}/archive/refs/tags/{tag}.tar.gz"),
sha256: None,
})
}
fn reversible_phase(
journal: &mut Journal<'_>,
sink: &mut dyn ProgressSink,
ctx: &EffectCtx<'_>,
phase: Phase,
targets: &[TargetPlan],
mut assets: Option<&mut Vec<String>>,
) -> Result<(), CutError> {
if phase_completed_ok(journal.state(), phase) {
return Ok(());
}
record(journal, sink, EventKind::PhaseEntered { phase })?;
for tp in targets {
if target_cleared(journal.state(), phase, &tp.id) {
continue;
}
let outcome = match phase {
Phase::DryRun => tp.adapter.dry_run(ctx, &tp.input).map(|_| ()),
Phase::Build => tp.adapter.build(ctx, &tp.input).map(|built| {
if let Some(sink) = assets.as_deref_mut() {
sink.extend(built.artifacts);
}
}),
Phase::Publish | Phase::Tag => unreachable!("reversible_phase only runs dry_run/build"),
};
match outcome {
Ok(()) => {
let ev = match phase {
Phase::DryRun => EventKind::TargetDryRun {
target: tp.id.clone(),
},
Phase::Build => EventKind::TargetBuilt {
target: tp.id.clone(),
},
_ => unreachable!(),
};
record(journal, sink, ev)?;
}
Err(e) => return fail_phase(journal, sink, phase, Some(tp.id.clone()), e.to_string()),
}
}
record(
journal,
sink,
EventKind::PhaseCompleted {
phase,
outcome: PhaseOutcome::Ok,
},
)?;
Ok(())
}
fn publish_phase(
journal: &mut Journal<'_>,
sink: &mut dyn ProgressSink,
ctx: &EffectCtx<'_>,
targets: &[TargetPlan],
) -> Result<(), CutError> {
let phase = Phase::Publish;
if phase_completed_ok(journal.state(), phase) {
return Ok(());
}
record(journal, sink, EventKind::PhaseEntered { phase })?;
for tp in targets {
if journal.state().published.contains_key(&tp.id) {
continue;
}
match tp.adapter.publish(ctx, &tp.input) {
Ok(receipt) => {
record(
journal,
sink,
EventKind::TargetPublished {
target: tp.id.clone(),
receipt: to_journal_receipt(&receipt),
},
)?;
}
Err(e) => return fail_phase(journal, sink, phase, Some(tp.id.clone()), e.to_string()),
}
}
record(
journal,
sink,
EventKind::PhaseCompleted {
phase,
outcome: PhaseOutcome::Ok,
},
)?;
Ok(())
}
fn tag_phase(
journal: &mut Journal<'_>,
sink: &mut dyn ProgressSink,
tagger: &dyn Tagger,
plan: &ReleasePlan,
) -> Result<(), CutError> {
let phase = Phase::Tag;
if phase_completed_ok(journal.state(), phase) {
return Ok(());
}
record(journal, sink, EventKind::PhaseEntered { phase })?;
let tag = format!("v{}", plan.version);
let title = format!("Release {}", plan.version);
if !tag_step_done(journal.state(), &tag, |s| s.created_local) {
if let Err(e) = tagger.create_tag(&tag, &plan.head_sha, &title) {
return fail_phase(journal, sink, phase, None, format!("create local tag: {e}"));
}
record(
journal,
sink,
EventKind::TagCreatedLocal { tag: tag.clone() },
)?;
}
if !tag_step_done(journal.state(), &tag, |s| s.pushed_remote) {
if let Err(e) = tagger.push_tag(&tag) {
return fail_phase(journal, sink, phase, None, format!("push tag: {e}"));
}
record(
journal,
sink,
EventKind::TagPushedRemote { tag: tag.clone() },
)?;
}
if !tag_step_done(journal.state(), &tag, |s| s.github_release) {
match tagger.create_github_release(&tag, &title) {
Ok(url) => record(
journal,
sink,
EventKind::GithubReleaseCreated {
tag: tag.clone(),
url,
},
)?,
Err(e) => {
return fail_phase(
journal,
sink,
phase,
None,
format!("create GitHub Release: {e}"),
)
}
}
}
record(
journal,
sink,
EventKind::PhaseCompleted {
phase,
outcome: PhaseOutcome::Ok,
},
)?;
Ok(())
}
fn fail_phase(
journal: &mut Journal<'_>,
sink: &mut dyn ProgressSink,
phase: Phase,
target: Option<String>,
message: String,
) -> Result<(), CutError> {
record(
journal,
sink,
EventKind::PhaseCompleted {
phase,
outcome: PhaseOutcome::Failed,
},
)?;
Err(CutError::PhaseFailed {
phase,
target,
message,
})
}
fn record(
journal: &mut Journal<'_>,
sink: &mut dyn ProgressSink,
kind: EventKind,
) -> Result<(), CutError> {
let idempotency_key = kind.idempotency_key();
let kind_for_sink = kind.clone();
let state = journal.append(kind).map_err(CutError::Journal)?;
let event = JournalEvent {
schema_version: JOURNAL_SCHEMA_VERSION,
seq: state.applied_seq,
ts: state.updated_ts,
idempotency_key,
kind: kind_for_sink,
};
sink.event(&event);
Ok(())
}
fn phase_completed_ok(state: &RunState, phase: Phase) -> bool {
state
.phases
.iter()
.any(|r| r.phase == phase && r.outcome == PhaseOutcome::Ok)
}
fn target_cleared(state: &RunState, phase: Phase, target: &str) -> bool {
match phase {
Phase::DryRun => state.dry_run.contains(target),
Phase::Build => state.built.contains(target),
Phase::Publish => state.published.contains_key(target),
Phase::Tag => false,
}
}
fn tag_step_done(
state: &RunState,
tag: &str,
pick: impl Fn(&crate::protocol::journal::TagState) -> bool,
) -> bool {
state.tags.get(tag).is_some_and(pick)
}
fn to_journal_receipt(r: &AdapterReceipt) -> JournalReceipt {
JournalReceipt {
ecosystem: r.ecosystem.as_str().to_string(),
package: Some(r.package.clone()),
version: r.version.clone(),
registry_url: r.remote_url.clone(),
digest: r.digest.clone(),
}
}
#[cfg(test)]
mod tests;