pub mod binary;
pub mod cargo;
pub mod go;
pub mod homebrew;
pub mod node;
pub mod python;
use std::time::Duration;
use crate::contract::schema::{Adapter, Ecosystem, Registry, Target};
use crate::ports::{Clock, CommandOutput, CommandRunner, RegistryQuery};
use crate::protocol::release::{
BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt, VerifyOutcome,
};
pub struct EffectCtx<'a> {
pub runner: &'a dyn CommandRunner,
pub clock: &'a dyn Clock,
pub registry: &'a dyn RegistryQuery,
pub repo_root: &'a std::path::Path,
pub artifacts: &'a ReleaseArtifacts,
}
impl<'a> EffectCtx<'a> {
#[must_use]
pub fn with_artifacts(&self, artifacts: &'a ReleaseArtifacts) -> EffectCtx<'a> {
EffectCtx { artifacts, ..*self }
}
#[must_use]
pub fn with_repo_root<'b>(&self, repo_root: &'b std::path::Path) -> EffectCtx<'b>
where
'a: 'b,
{
EffectCtx {
runner: self.runner,
clock: self.clock,
registry: self.registry,
repo_root,
artifacts: self.artifacts,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReleaseArtifacts {
pub assets: Vec<String>,
pub source_tarball: Option<SourceTarball>,
pub repo_slug: Option<String>,
pub homebrew: Option<HomebrewFormula>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HomebrewFormula {
pub tap: Option<String>,
pub license: Option<String>,
}
pub static EMPTY_ARTIFACTS: ReleaseArtifacts = ReleaseArtifacts {
assets: Vec::new(),
source_tarball: None,
repo_slug: None,
homebrew: None,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceTarball {
pub url: String,
pub sha256: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdapterTarget {
pub target: Target,
pub package: String,
pub version: String,
}
impl AdapterTarget {
#[must_use]
pub fn ecosystem(&self) -> Ecosystem {
self.target.ecosystem
}
#[must_use]
pub fn canonical_ref(&self) -> String {
format!(
"{}/{}@{}",
self.target.registry.as_str(),
self.package,
self.version
)
}
}
#[derive(Debug)]
pub enum AdapterError {
Command {
command: String,
code: Option<i32>,
stderr: String,
},
Io {
command: String,
source: String,
},
Filesystem {
path: String,
source: String,
},
Unsupported {
adapter: Adapter,
operation: &'static str,
},
IndexTimeout {
package: String,
version: String,
waited_secs: u64,
},
RegistryUnavailable {
package: String,
version: String,
source: String,
},
PublishNotVisible {
package: String,
version: String,
waited_secs: u64,
},
DigestMismatch {
package: String,
version: String,
local: String,
remote: String,
},
UnsupportedRegistry {
adapter: Adapter,
registry: Registry,
},
}
impl std::fmt::Display for AdapterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Command {
command,
code,
stderr,
} => {
let code = code.map_or_else(|| "signal".to_string(), |c| c.to_string());
write!(f, "`{command}` failed (exit {code}): {}", stderr.trim())
}
Self::Io { command, source } => write!(f, "cannot run `{command}`: {source}"),
Self::Filesystem { path, source } => {
write!(f, "cannot write `{path}`: {source}")
}
Self::Unsupported { adapter, operation } => write!(
f,
"adapter `{}` does not support `{operation}` from this host",
adapter.as_str()
),
Self::IndexTimeout {
package,
version,
waited_secs,
} => write!(
f,
"`{package}@{version}` was not visible on the registry index within \
{waited_secs}s; a crate that depends on it cannot be published until it is. \
If `{package}` is a workspace crate, ensure it is declared as its own release \
target and that its publish succeeded"
),
Self::RegistryUnavailable {
package,
version,
source,
} => write!(
f,
"cannot reach the registry to determine the published state of \
`{package}@{version}` (registry unreachable: {source}); failing closed rather \
than risk an unsafe publish decision"
),
Self::PublishNotVisible {
package,
version,
waited_secs,
} => write!(
f,
"`cargo publish` of `{package}@{version}` exited successfully, but the version was \
not visible on the crates.io index within {waited_secs}s. The upload MAY have \
landed (a slow index) — run `ossctl release verify`/`resume` once the index \
catches up rather than re-publishing blindly. If it never appears, the publish \
was a silent no-op: check the registry credentials/config and that `{package}` is \
a correctly-declared crates.io target. The cut fails here rather than record a \
receipt for a publish it cannot confirm"
),
Self::DigestMismatch {
package,
version,
local,
remote,
} => write!(
f,
"refusing to skip the publish of `{package}@{version}`: it is already on the \
registry, but the crate published there (sha256 {remote}) is NOT byte-identical \
to the artifact this cut would upload (sha256 {local}). The registry holds a \
different artifact at this version than this cut intended — investigate (a \
non-reproducible build/toolchain, or a supply-chain substitution) before \
proceeding. The cut fails here rather than skip and record a receipt for a crate \
it did not publish"
),
Self::UnsupportedRegistry { adapter, registry } => write!(
f,
"adapter `{}` does not support publishing to registry `{}`; it publishes only to \
crates.io. Refusing before any publish rather than risk landing on the wrong \
registry — fix the target's `registry` in the contract",
adapter.as_str(),
registry.as_str()
),
}
}
}
impl std::error::Error for AdapterError {}
pub trait ReleaseAdapter {
fn adapter(&self) -> Adapter;
fn is_ci_delegated(&self) -> bool {
false
}
fn ci_owns_github_release(&self) -> bool {
false
}
fn dry_run(
&self,
ctx: &EffectCtx<'_>,
target: &AdapterTarget,
) -> Result<DryRunReport, AdapterError>;
fn build(
&self,
ctx: &EffectCtx<'_>,
target: &AdapterTarget,
) -> Result<BuildArtifacts, AdapterError>;
fn publish(
&self,
ctx: &EffectCtx<'_>,
target: &AdapterTarget,
) -> Result<PublishReceipt, AdapterError>;
fn verify(
&self,
ctx: &EffectCtx<'_>,
receipt: &PublishReceipt,
) -> Result<VerifyOutcome, AdapterError> {
Ok(verify_via_registry(ctx, receipt))
}
fn timeout(&self) -> Duration;
}
pub enum EcosystemAdapter {
Rust(cargo::CargoAdapter),
Node(node::NodeAdapter),
Python(python::PythonAdapter),
Go(go::GoAdapter),
Homebrew(homebrew::HomebrewAdapter),
Binary(binary::BinaryAdapter),
}
#[must_use]
pub fn resolve(adapter: Adapter) -> EcosystemAdapter {
match adapter {
Adapter::CargoPublish | Adapter::CargoDist => {
EcosystemAdapter::Rust(cargo::CargoAdapter::new(adapter))
}
Adapter::ReleasePlease | Adapter::Changesets | Adapter::NpmPublish => {
EcosystemAdapter::Node(node::NodeAdapter::new(adapter))
}
Adapter::GhActionPypiPublish | Adapter::Twine => {
EcosystemAdapter::Python(python::PythonAdapter::new(adapter))
}
Adapter::Goreleaser => EcosystemAdapter::Go(go::GoAdapter::new(adapter)),
Adapter::HomebrewTap | Adapter::HomebrewCore => {
EcosystemAdapter::Homebrew(homebrew::HomebrewAdapter::new(adapter))
}
Adapter::Manual => EcosystemAdapter::Binary(binary::BinaryAdapter::new(adapter)),
}
}
impl EcosystemAdapter {
fn inner(&self) -> &dyn ReleaseAdapter {
match self {
Self::Rust(a) => a,
Self::Node(a) => a,
Self::Python(a) => a,
Self::Go(a) => a,
Self::Homebrew(a) => a,
Self::Binary(a) => a,
}
}
}
impl ReleaseAdapter for EcosystemAdapter {
fn adapter(&self) -> Adapter {
self.inner().adapter()
}
fn is_ci_delegated(&self) -> bool {
self.inner().is_ci_delegated()
}
fn ci_owns_github_release(&self) -> bool {
self.inner().ci_owns_github_release()
}
fn dry_run(
&self,
ctx: &EffectCtx<'_>,
target: &AdapterTarget,
) -> Result<DryRunReport, AdapterError> {
self.inner().dry_run(ctx, target)
}
fn build(
&self,
ctx: &EffectCtx<'_>,
target: &AdapterTarget,
) -> Result<BuildArtifacts, AdapterError> {
self.inner().build(ctx, target)
}
fn publish(
&self,
ctx: &EffectCtx<'_>,
target: &AdapterTarget,
) -> Result<PublishReceipt, AdapterError> {
self.inner().publish(ctx, target)
}
fn verify(
&self,
ctx: &EffectCtx<'_>,
receipt: &PublishReceipt,
) -> Result<VerifyOutcome, AdapterError> {
self.inner().verify(ctx, receipt)
}
fn timeout(&self) -> Duration {
self.inner().timeout()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteObservation {
pub published_versions: Vec<String>,
pub remote_digest: Option<String>,
}
#[must_use]
pub fn classify_receipt(
receipt: &PublishReceipt,
observed: Option<&RemoteObservation>,
) -> VerifyOutcome {
let Some(obs) = observed else {
return VerifyOutcome::Unknown;
};
if !obs.published_versions.iter().any(|v| v == &receipt.version) {
return VerifyOutcome::Missing;
}
match (&receipt.digest, &obs.remote_digest) {
(Some(local), Some(remote)) if local != remote => VerifyOutcome::Conflicts,
_ => VerifyOutcome::Matches,
}
}
pub(crate) fn verify_via_registry(ctx: &EffectCtx<'_>, receipt: &PublishReceipt) -> VerifyOutcome {
let observed = match ctx
.registry
.published_versions(receipt.ecosystem.as_str(), &receipt.package)
{
Ok(versions) => Some(RemoteObservation {
published_versions: versions,
remote_digest: None,
}),
Err(_) => None,
};
classify_receipt(receipt, observed.as_ref())
}
pub(crate) fn run_all(
ctx: &EffectCtx<'_>,
commands: &[PlannedCommand],
) -> Result<Vec<CommandOutput>, AdapterError> {
let mut outputs = Vec::with_capacity(commands.len());
for cmd in commands {
let args: Vec<&str> = cmd.args.iter().map(String::as_str).collect();
let out = ctx
.runner
.run(&cmd.program, &args, ctx.repo_root)
.map_err(|e| AdapterError::Io {
command: cmd.rendered(),
source: e.to_string(),
})?;
if out.status != Some(0) {
let detail = if out.stderr.trim().is_empty() {
out.stdout
} else {
out.stderr
};
return Err(AdapterError::Command {
command: cmd.rendered(),
code: out.status,
stderr: detail,
});
}
outputs.push(out);
}
Ok(outputs)
}
pub(crate) fn hash_file(ctx: &EffectCtx<'_>, path: &str) -> Result<String, String> {
let candidates: [(&str, Vec<&str>); 2] = [
("sha256sum", vec!["--", path]),
("shasum", vec!["-a", "256", "--", path]),
];
let mut last = String::from("no SHA-256 tool succeeded");
for (program, args) in &candidates {
match ctx.runner.run(program, args, ctx.repo_root) {
Ok(out) if out.status == Some(0) => match parse_sha256_hex(&out.stdout) {
Some(digest) => return Ok(digest),
None => {
last = format!(
"`{program}` produced no parseable sha256: {:?}",
out.stdout.trim()
);
}
},
Ok(out) => {
last = format!(
"`{program}` exited {}",
out.status
.map_or_else(|| "signal".to_string(), |c| c.to_string())
);
}
Err(e) => last = format!("cannot run `{program}`: {e}"),
}
}
Err(format!(
"could not compute the sha256 of `{path}` (tried sha256sum, shasum): {last}"
))
}
pub(crate) fn parse_sha256_hex(stdout: &str) -> Option<String> {
stdout
.split_whitespace()
.find(|tok| tok.len() == 64 && tok.bytes().all(|b| b.is_ascii_hexdigit()))
.map(str::to_ascii_lowercase)
}
pub(crate) fn make_receipt(
ctx: &EffectCtx<'_>,
target: &AdapterTarget,
digest: Option<String>,
remote_url: Option<String>,
) -> PublishReceipt {
PublishReceipt {
adapter: target.target.adapter,
ecosystem: target.ecosystem(),
package: target.package.clone(),
version: target.version.clone(),
canonical_ref: target.canonical_ref(),
digest,
remote_url,
timestamp: ctx.clock.now_unix(),
}
}
#[cfg(test)]
mod tests;