Skip to main content

ossctl_core/release/adapters/
binary.rs

1//! Binary distribution adapter: `manual` / GitHub Releases.
2//!
3//! Attaches prebuilt binaries to a GitHub Release (`gh release`). GitHub
4//! Releases are not observable through the
5//! [`RegistryQuery`](crate::ports::RegistryQuery) port, so `verify` returns
6//! [`VerifyOutcome::Unknown`] **explicitly** (ADR-0002 §1) — an honest "cannot
7//! check", never a false `Missing`.
8
9use std::time::Duration;
10
11use crate::contract::schema::Adapter;
12use crate::protocol::release::{
13    BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt, VerifyOutcome,
14};
15
16use super::{make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, ReleaseAdapter};
17
18/// The binary distribution adapter, operating as `manual` (GitHub Releases).
19pub struct BinaryAdapter {
20    adapter: Adapter,
21}
22
23impl BinaryAdapter {
24    /// Construct for the resolved `manual` adapter identity.
25    #[must_use]
26    pub fn new(adapter: Adapter) -> Self {
27        debug_assert!(matches!(adapter, Adapter::Manual));
28        Self { adapter }
29    }
30
31    fn tag(t: &AdapterTarget) -> String {
32        format!("v{}", t.version)
33    }
34}
35
36impl ReleaseAdapter for BinaryAdapter {
37    fn adapter(&self) -> Adapter {
38        self.adapter
39    }
40
41    fn dry_run(
42        &self,
43        _ctx: &EffectCtx<'_>,
44        t: &AdapterTarget,
45    ) -> Result<DryRunReport, AdapterError> {
46        Ok(DryRunReport {
47            adapter: self.adapter,
48            planned_commands: vec![PlannedCommand::new(
49                "gh",
50                &["release", "view", &Self::tag(t)],
51            )],
52            notes: vec!["artifacts are built by the ecosystem's own build step and \
53                 uploaded to the coordinator-owned GitHub Release"
54                .to_string()],
55        })
56    }
57
58    fn build(
59        &self,
60        _ctx: &EffectCtx<'_>,
61        _t: &AdapterTarget,
62    ) -> Result<BuildArtifacts, AdapterError> {
63        // The binary target uploads artifacts produced elsewhere; it has no
64        // build phase of its own.
65        Ok(BuildArtifacts {
66            adapter: self.adapter,
67            artifacts: vec![],
68            notes: vec![
69                "binary target has no build phase (uploads prebuilt artifacts)".to_string(),
70            ],
71        })
72    }
73
74    fn publish(
75        &self,
76        ctx: &EffectCtx<'_>,
77        t: &AdapterTarget,
78    ) -> Result<PublishReceipt, AdapterError> {
79        // PER-TARGET IRREVERSIBLE (uploads assets to the release).
80        // The concrete asset paths are threaded in via `ctx.artifacts.assets`
81        // (gathered from every target's build). Flags precede the `--` option
82        // terminator, and every asset path follows it, so a path that happens to
83        // start with `-` is never mis-read as a flag.
84        //
85        // Pin the upload to the coordinator-resolved slug with `--repo` (when
86        // known) rather than letting `gh` resolve the repository ambiently from the
87        // cwd/remotes/`GH_REPO` — otherwise the upload target could differ from the
88        // `remote_url` the receipt records below.
89        let tag = Self::tag(t);
90        let slug = ctx.artifacts.repo_slug.as_deref();
91        let mut args = vec!["release", "upload", tag.as_str()];
92        if let Some(slug) = slug {
93            args.push("--repo");
94            args.push(slug);
95        }
96        args.push("--clobber");
97        args.push("--");
98        args.extend(ctx.artifacts.assets.iter().map(String::as_str));
99        run_all(ctx, &[PlannedCommand::new("gh", &args)])?;
100        // Record where the assets landed: the GitHub-Release page for this tag,
101        // built from the same slug the upload targeted. GitHub Releases expose no
102        // single publish digest, so `digest` stays `None` (honest — the receipt
103        // type documents `None` for ecosystems without one); `remote_url` is `None`
104        // when the cut has no resolvable GitHub remote.
105        let remote_url = slug.map(|slug| format!("https://github.com/{slug}/releases/tag/{tag}"));
106        Ok(make_receipt(ctx, t, None, remote_url))
107    }
108
109    fn verify(
110        &self,
111        _ctx: &EffectCtx<'_>,
112        _receipt: &PublishReceipt,
113    ) -> Result<VerifyOutcome, AdapterError> {
114        // GitHub Releases are not observable through RegistryQuery; report the
115        // honest "cannot check" rather than a false Missing (ADR-0002 §1).
116        Ok(VerifyOutcome::Unknown)
117    }
118
119    fn timeout(&self) -> Duration {
120        Duration::from_secs(600)
121    }
122}