shipshape_core/release/adapters/
binary.rs1use 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
20pub struct BinaryAdapter {
22 adapter: Adapter,
23}
24
25impl BinaryAdapter {
26 #[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
64fn 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 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
98pub(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
125pub(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 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 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 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}