Skip to main content

shipshape_core/release/adapters/
binary.rs

1//! Binary distribution adapter: `manual` / GitHub Releases.
2//!
3//! Attaches prebuilt binaries to a GitHub Release (`gh release`). `verify` uses
4//! a read-only `gh release view` observation and requires uploaded assets. A
5//! command transport failure is `Unknown`; an absent Release or empty asset set is
6//! `Missing`.
7
8use std::collections::BTreeSet;
9use std::time::Duration;
10
11use serde::Deserialize;
12
13use crate::contract::schema::Adapter;
14use crate::protocol::release::{
15    BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt, VerifyOutcome,
16};
17
18use super::{make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, ReleaseAdapter};
19
20/// The binary distribution adapter, operating as `manual` (GitHub Releases).
21pub struct BinaryAdapter {
22    adapter: Adapter,
23}
24
25impl BinaryAdapter {
26    /// Construct for the resolved `manual` adapter identity.
27    #[must_use]
28    pub fn new(adapter: Adapter) -> Self {
29        debug_assert!(matches!(adapter, Adapter::Manual));
30        Self { adapter }
31    }
32
33    fn tag(t: &AdapterTarget) -> String {
34        format!("v{}", t.version)
35    }
36}
37
38#[derive(Deserialize)]
39#[serde(rename_all = "camelCase")]
40struct GithubRelease {
41    assets: Vec<GithubAsset>,
42    is_draft: bool,
43    tag_name: String,
44}
45
46#[derive(Deserialize)]
47struct GithubAsset {
48    name: String,
49}
50
51#[derive(Deserialize)]
52struct DistManifest {
53    announcement_tag: String,
54    releases: Vec<DistRelease>,
55}
56
57#[derive(Deserialize)]
58struct DistRelease {
59    app_name: String,
60    app_version: String,
61    artifacts: Vec<String>,
62}
63
64/// Read the published GitHub Release at the stable `v<version>` tag. A malformed
65/// response or command failure is an unobservable destination (`Unknown`), never
66/// evidence that a Release is absent.
67fn release_asset_names(
68    ctx: &EffectCtx<'_>,
69    version: &str,
70) -> Result<BTreeSet<String>, VerifyOutcome> {
71    let tag = format!("v{version}");
72    let out = ctx
73        .runner
74        .run(
75            "gh",
76            &["release", "view", &tag, "--json", "assets,isDraft,tagName"],
77            ctx.repo_root,
78        )
79        .map_err(|_| VerifyOutcome::Unknown)?;
80    if out.status != Some(0) {
81        // `gh` unfortunately uses exit 1 for both a definite absence and failures
82        // to observe (auth, rate limits, transport). Its stable not-found diagnostic
83        // is the only case that proves `Missing`; every other failure stays Unknown.
84        return Err(if out.stderr.trim() == "release not found" {
85            VerifyOutcome::Missing
86        } else {
87            VerifyOutcome::Unknown
88        });
89    }
90    let release: GithubRelease =
91        serde_json::from_str(&out.stdout).map_err(|_| VerifyOutcome::Unknown)?;
92    if release.tag_name != tag || release.is_draft {
93        return Err(VerifyOutcome::Missing);
94    }
95    Ok(release.assets.into_iter().map(|asset| asset.name).collect())
96}
97
98/// Observe a GitHub Release by tag and require its uploaded asset set. The
99/// release title is deliberately irrelevant: cargo-dist commonly formats it as
100/// `<version> - <date>`, while the stable lookup coordinate is the `v<version>` tag.
101/// An empty `expected_assets` slice still requires at least one asset, which is
102/// the strongest check available when reconciling a pre-plan-store journal.
103pub(super) fn observe_release_assets(
104    ctx: &EffectCtx<'_>,
105    version: &str,
106    expected_assets: &[String],
107) -> VerifyOutcome {
108    let observed = match release_asset_names(ctx, version) {
109        Ok(observed) => observed,
110        Err(outcome) => return outcome,
111    };
112    if if expected_assets.is_empty() {
113        !observed.is_empty()
114    } else {
115        expected_assets
116            .iter()
117            .all(|wanted| observed.contains(wanted))
118    } {
119        VerifyOutcome::Matches
120    } else {
121        VerifyOutcome::Missing
122    }
123}
124
125/// Observe cargo-dist's authoritative inventory on the published tagged Release.
126/// The sealed contract's platform list is install policy and can deliberately be
127/// broader than cargo-dist's configured targets, so archive names must come from
128/// cargo-dist's manifest instead. Verification downloads that manifest, finds the
129/// declared package and version, and requires every artifact cargo-dist records for
130/// it to be present on the same Release. This avoids both the historical false red
131/// and a false green while uploads are incomplete.
132pub(super) fn observe_cargo_dist_release(
133    ctx: &EffectCtx<'_>,
134    version: &str,
135    package: &str,
136) -> VerifyOutcome {
137    const MANIFEST: &str = "dist-manifest.json";
138    let observed = match release_asset_names(ctx, version) {
139        Ok(observed) => observed,
140        Err(outcome) => return outcome,
141    };
142    if !observed.contains(MANIFEST) {
143        return VerifyOutcome::Missing;
144    }
145
146    let tag = format!("v{version}");
147    let out = match ctx.runner.run(
148        "gh",
149        &[
150            "release",
151            "download",
152            &tag,
153            "--pattern",
154            MANIFEST,
155            "--output",
156            "-",
157        ],
158        ctx.repo_root,
159    ) {
160        Ok(out) if out.status == Some(0) => out,
161        _ => return VerifyOutcome::Unknown,
162    };
163    let manifest: DistManifest = match serde_json::from_str(&out.stdout) {
164        Ok(manifest) => manifest,
165        Err(_) => return VerifyOutcome::Unknown,
166    };
167    if manifest.announcement_tag != tag {
168        return VerifyOutcome::Conflicts;
169    }
170    let Some(release) = manifest
171        .releases
172        .iter()
173        .find(|release| release.app_name == package)
174    else {
175        return VerifyOutcome::Unknown;
176    };
177    if release.app_version != version {
178        return VerifyOutcome::Conflicts;
179    }
180    if !release.artifacts.is_empty()
181        && release
182            .artifacts
183            .iter()
184            .all(|artifact| observed.contains(artifact))
185    {
186        VerifyOutcome::Matches
187    } else {
188        VerifyOutcome::Missing
189    }
190}
191
192impl ReleaseAdapter for BinaryAdapter {
193    fn adapter(&self) -> Adapter {
194        self.adapter
195    }
196
197    fn dry_run(
198        &self,
199        _ctx: &EffectCtx<'_>,
200        t: &AdapterTarget,
201    ) -> Result<DryRunReport, AdapterError> {
202        Ok(DryRunReport {
203            adapter: self.adapter,
204            planned_commands: vec![PlannedCommand::new(
205                "gh",
206                &["release", "view", &Self::tag(t)],
207            )],
208            notes: vec!["artifacts are built by the ecosystem's own build step and \
209                 uploaded to the coordinator-owned GitHub Release"
210                .to_string()],
211        })
212    }
213
214    fn build(
215        &self,
216        _ctx: &EffectCtx<'_>,
217        _t: &AdapterTarget,
218    ) -> Result<BuildArtifacts, AdapterError> {
219        // The binary target uploads artifacts produced elsewhere; it has no
220        // build phase of its own.
221        Ok(BuildArtifacts {
222            adapter: self.adapter,
223            artifacts: vec![],
224            notes: vec![
225                "binary target has no build phase (uploads prebuilt artifacts)".to_string(),
226            ],
227        })
228    }
229
230    fn publish(
231        &self,
232        ctx: &EffectCtx<'_>,
233        t: &AdapterTarget,
234    ) -> Result<PublishReceipt, AdapterError> {
235        // PER-TARGET IRREVERSIBLE (uploads assets to the release).
236        // The concrete asset paths are threaded in via `ctx.artifacts.assets`
237        // (gathered from every target's build). Flags precede the `--` option
238        // terminator, and every asset path follows it, so a path that happens to
239        // start with `-` is never mis-read as a flag.
240        //
241        // Pin the upload to the coordinator-resolved slug with `--repo` (when
242        // known) rather than letting `gh` resolve the repository ambiently from the
243        // cwd/remotes/`GH_REPO` — otherwise the upload target could differ from the
244        // `remote_url` the receipt records below.
245        let tag = Self::tag(t);
246        let slug = ctx.artifacts.repo_slug.as_deref();
247        let mut args = vec!["release", "upload", tag.as_str()];
248        if let Some(slug) = slug {
249            args.push("--repo");
250            args.push(slug);
251        }
252        args.push("--clobber");
253        args.push("--");
254        args.extend(ctx.artifacts.assets.iter().map(String::as_str));
255        run_all(ctx, &[PlannedCommand::new("gh", &args)])?;
256        // Record where the assets landed: the GitHub-Release page for this tag,
257        // built from the same slug the upload targeted. GitHub Releases expose no
258        // single publish digest, so `digest` stays `None` (honest — the receipt
259        // type documents `None` for ecosystems without one); `remote_url` is `None`
260        // when the cut has no resolvable GitHub remote.
261        let remote_url = slug.map(|slug| format!("https://github.com/{slug}/releases/tag/{tag}"));
262        Ok(make_receipt(ctx, t, None, remote_url))
263    }
264
265    fn verify(
266        &self,
267        ctx: &EffectCtx<'_>,
268        receipt: &PublishReceipt,
269    ) -> Result<VerifyOutcome, AdapterError> {
270        Ok(observe_release_assets(ctx, &receipt.version, &[]))
271    }
272
273    fn timeout(&self) -> Duration {
274        Duration::from_secs(600)
275    }
276}