use std::collections::BTreeSet;
use std::time::Duration;
use serde::Deserialize;
use crate::contract::schema::Adapter;
use crate::protocol::release::{
BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt, VerifyOutcome,
};
use super::{make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, ReleaseAdapter};
pub struct BinaryAdapter {
adapter: Adapter,
}
impl BinaryAdapter {
#[must_use]
pub fn new(adapter: Adapter) -> Self {
debug_assert!(matches!(adapter, Adapter::Manual));
Self { adapter }
}
fn tag(t: &AdapterTarget) -> String {
format!("v{}", t.version)
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GithubRelease {
assets: Vec<GithubAsset>,
is_draft: bool,
tag_name: String,
}
#[derive(Deserialize)]
struct GithubAsset {
name: String,
}
#[derive(Deserialize)]
struct DistManifest {
announcement_tag: String,
releases: Vec<DistRelease>,
}
#[derive(Deserialize)]
struct DistRelease {
app_name: String,
app_version: String,
artifacts: Vec<String>,
}
fn release_asset_names(
ctx: &EffectCtx<'_>,
version: &str,
) -> Result<BTreeSet<String>, VerifyOutcome> {
let tag = format!("v{version}");
let out = ctx
.runner
.run(
"gh",
&["release", "view", &tag, "--json", "assets,isDraft,tagName"],
ctx.repo_root,
)
.map_err(|_| VerifyOutcome::Unknown)?;
if out.status != Some(0) {
return Err(if out.stderr.trim() == "release not found" {
VerifyOutcome::Missing
} else {
VerifyOutcome::Unknown
});
}
let release: GithubRelease =
serde_json::from_str(&out.stdout).map_err(|_| VerifyOutcome::Unknown)?;
if release.tag_name != tag || release.is_draft {
return Err(VerifyOutcome::Missing);
}
Ok(release.assets.into_iter().map(|asset| asset.name).collect())
}
pub(super) fn observe_release_assets(
ctx: &EffectCtx<'_>,
version: &str,
expected_assets: &[String],
) -> VerifyOutcome {
let observed = match release_asset_names(ctx, version) {
Ok(observed) => observed,
Err(outcome) => return outcome,
};
if if expected_assets.is_empty() {
!observed.is_empty()
} else {
expected_assets
.iter()
.all(|wanted| observed.contains(wanted))
} {
VerifyOutcome::Matches
} else {
VerifyOutcome::Missing
}
}
pub(super) fn observe_cargo_dist_release(
ctx: &EffectCtx<'_>,
version: &str,
package: &str,
) -> VerifyOutcome {
const MANIFEST: &str = "dist-manifest.json";
let observed = match release_asset_names(ctx, version) {
Ok(observed) => observed,
Err(outcome) => return outcome,
};
if !observed.contains(MANIFEST) {
return VerifyOutcome::Missing;
}
let tag = format!("v{version}");
let out = match ctx.runner.run(
"gh",
&[
"release",
"download",
&tag,
"--pattern",
MANIFEST,
"--output",
"-",
],
ctx.repo_root,
) {
Ok(out) if out.status == Some(0) => out,
_ => return VerifyOutcome::Unknown,
};
let manifest: DistManifest = match serde_json::from_str(&out.stdout) {
Ok(manifest) => manifest,
Err(_) => return VerifyOutcome::Unknown,
};
if manifest.announcement_tag != tag {
return VerifyOutcome::Conflicts;
}
let Some(release) = manifest
.releases
.iter()
.find(|release| release.app_name == package)
else {
return VerifyOutcome::Unknown;
};
if release.app_version != version {
return VerifyOutcome::Conflicts;
}
if !release.artifacts.is_empty()
&& release
.artifacts
.iter()
.all(|artifact| observed.contains(artifact))
{
VerifyOutcome::Matches
} else {
VerifyOutcome::Missing
}
}
impl ReleaseAdapter for BinaryAdapter {
fn adapter(&self) -> Adapter {
self.adapter
}
fn dry_run(
&self,
_ctx: &EffectCtx<'_>,
t: &AdapterTarget,
) -> Result<DryRunReport, AdapterError> {
Ok(DryRunReport {
adapter: self.adapter,
planned_commands: vec![PlannedCommand::new(
"gh",
&["release", "view", &Self::tag(t)],
)],
notes: vec!["artifacts are built by the ecosystem's own build step and \
uploaded to the coordinator-owned GitHub Release"
.to_string()],
})
}
fn build(
&self,
_ctx: &EffectCtx<'_>,
_t: &AdapterTarget,
) -> Result<BuildArtifacts, AdapterError> {
Ok(BuildArtifacts {
adapter: self.adapter,
artifacts: vec![],
notes: vec![
"binary target has no build phase (uploads prebuilt artifacts)".to_string(),
],
})
}
fn publish(
&self,
ctx: &EffectCtx<'_>,
t: &AdapterTarget,
) -> Result<PublishReceipt, AdapterError> {
let tag = Self::tag(t);
let slug = ctx.artifacts.repo_slug.as_deref();
let mut args = vec!["release", "upload", tag.as_str()];
if let Some(slug) = slug {
args.push("--repo");
args.push(slug);
}
args.push("--clobber");
args.push("--");
args.extend(ctx.artifacts.assets.iter().map(String::as_str));
run_all(ctx, &[PlannedCommand::new("gh", &args)])?;
let remote_url = slug.map(|slug| format!("https://github.com/{slug}/releases/tag/{tag}"));
Ok(make_receipt(ctx, t, None, remote_url))
}
fn verify(
&self,
ctx: &EffectCtx<'_>,
receipt: &PublishReceipt,
) -> Result<VerifyOutcome, AdapterError> {
Ok(observe_release_assets(ctx, &receipt.version, &[]))
}
fn timeout(&self) -> Duration {
Duration::from_secs(600)
}
}