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}