1use super::{
2 PublicationReceipt, PublicationState, Publisher, VerificationReport, receipt, validate_manifest,
3};
4use crate::command::{CommandRequest, CommandRunner};
5use crate::config::PublisherConfig;
6use crate::domain::{ArtifactIdentity, ArtifactManifest, PreparedArtifact, TargetPlan};
7use crate::lifecycle::expand;
8use anyhow::{Context, Result, bail};
9use serde::Deserialize;
10use sha2::{Digest, Sha256};
11use std::collections::HashSet;
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16pub struct GithubReleasePublisher {
17 root: PathBuf,
18 repository: String,
19 name: String,
20 title: String,
21 prerelease: bool,
22 runner: Arc<dyn CommandRunner>,
23}
24
25#[derive(Clone, Debug, Deserialize)]
26struct ReleaseInfo {
27 tag_name: String,
28 prerelease: bool,
29 #[serde(default)]
30 draft: bool,
31 assets: Vec<ReleaseAsset>,
32}
33
34#[derive(Clone, Debug, Deserialize)]
35struct ReleaseAsset {
36 name: String,
37}
38
39impl GithubReleasePublisher {
40 pub fn new(
41 root: impl Into<PathBuf>,
42 repository: &str,
43 name: &str,
44 config: &PublisherConfig,
45 runner: Arc<dyn CommandRunner>,
46 ) -> Result<Self> {
47 let PublisherConfig::GithubRelease { title, prerelease } = config else {
48 bail!("publisher `{name}` is not a github_release publisher");
49 };
50 Ok(Self {
51 root: root.into(),
52 repository: repository.to_owned(),
53 name: name.to_owned(),
54 title: title.clone(),
55 prerelease: *prerelease,
56 runner,
57 })
58 }
59
60 fn release_info(&self, plan: &TargetPlan) -> Result<Option<ReleaseInfo>> {
61 let endpoint = format!(
62 "repos/{}/releases/tags/{}",
63 self.repository, plan.release.tag
64 );
65 let request = CommandRequest::new("gh", ["api", endpoint.as_str()], &self.root);
66 let result = self.runner.execute(&request)?;
67 if result.status != 0 {
68 if result.stderr.contains("404") || result.stderr.contains("Not Found") {
69 return Ok(None);
70 }
71 return Err(result
72 .require_success("query GitHub Release")
73 .expect_err("non-zero command must fail"));
74 }
75 let info: ReleaseInfo =
76 serde_json::from_str(&result.stdout).context("invalid GitHub Release response")?;
77 Ok(Some(info))
78 }
79
80 fn plan_for_manifest(manifest: &ArtifactManifest) -> TargetPlan {
81 TargetPlan {
82 name: manifest.target.clone(),
83 publisher: manifest.publisher.clone(),
84 release: manifest.release.clone(),
85 artifacts: manifest
86 .artifacts
87 .iter()
88 .map(|artifact| artifact.identity.clone())
89 .collect(),
90 }
91 }
92
93 fn inspect_info(&self, plan: &TargetPlan, info: Option<&ReleaseInfo>) -> PublicationState {
94 let Some(info) = info else {
95 return PublicationState::Absent;
96 };
97 if info.tag_name != plan.release.tag.to_string() {
98 return PublicationState::Invalid {
99 reason: format!(
100 "GitHub Release tag is {}, expected {}",
101 info.tag_name, plan.release.tag
102 ),
103 };
104 }
105 if info.draft {
106 return PublicationState::Invalid {
107 reason: "GitHub Release is still a draft; automatic publication is unsafe"
108 .to_owned(),
109 };
110 }
111 if info.prerelease != self.prerelease {
112 return PublicationState::Invalid {
113 reason: format!(
114 "GitHub Release prerelease is {}, expected {}",
115 info.prerelease, self.prerelease
116 ),
117 };
118 }
119 let remote: HashSet<_> = info
120 .assets
121 .iter()
122 .map(|asset| asset.name.as_str())
123 .collect();
124 if remote.len() != info.assets.len() {
125 return PublicationState::Invalid {
126 reason: "GitHub Release contains duplicate asset names".to_owned(),
127 };
128 }
129 let expected = expected_asset_names(plan);
130 let present: Vec<_> = expected
131 .iter()
132 .filter(|name| remote.contains(name.as_str()))
133 .cloned()
134 .collect();
135 let missing: Vec<_> = expected
136 .iter()
137 .filter(|name| !remote.contains(name.as_str()))
138 .cloned()
139 .collect();
140 if present.is_empty() {
141 PublicationState::Absent
142 } else if missing.is_empty() {
143 PublicationState::Complete
144 } else {
145 PublicationState::Partial { present, missing }
146 }
147 }
148
149 fn upload_missing(&self, manifest: &ArtifactManifest, missing: &[String]) -> Result<()> {
150 let mut arguments = vec![
151 "release".to_owned(),
152 "upload".to_owned(),
153 manifest.release.tag.to_string(),
154 ];
155 for name in missing {
156 let artifact = artifact_by_name(manifest, name)?;
157 arguments.push(artifact.path.display().to_string());
158 }
159 arguments.extend(["--repo".to_owned(), self.repository.clone()]);
160 self.run(arguments, "upload GitHub Release assets")?;
161 Ok(())
162 }
163
164 fn create(&self, manifest: &ArtifactManifest) -> Result<()> {
165 let version = manifest.release.tag.to_string();
166 let title = expand(&self.title, &version, None)?;
167 let mut arguments = vec!["release".to_owned(), "create".to_owned(), version];
168 for artifact in &manifest.artifacts {
169 arguments.push(artifact.path.display().to_string());
170 }
171 arguments.extend([
172 "--repo".to_owned(),
173 self.repository.clone(),
174 "--verify-tag".to_owned(),
175 "--target".to_owned(),
176 manifest.release.commit.clone(),
177 "--title".to_owned(),
178 title,
179 "--notes".to_owned(),
180 format!(
181 "Commit: `{}`\nTool: `release-tool/{}`",
182 manifest.release.commit,
183 env!("CARGO_PKG_VERSION")
184 ),
185 "--latest=false".to_owned(),
186 ]);
187 if self.prerelease {
188 arguments.push("--prerelease".to_owned());
189 }
190 self.run(arguments, "create GitHub Release")?;
191 Ok(())
192 }
193
194 fn verify_selected(
195 &self,
196 manifest: &ArtifactManifest,
197 names: impl IntoIterator<Item = String>,
198 ) -> Result<Vec<String>> {
199 let temporary = tempfile::tempdir().context("failed to create verification directory")?;
200 let mut verified = Vec::new();
201 for name in names {
202 let expected = artifact_by_name(manifest, &name)?;
203 let directory = temporary.path().join(verified.len().to_string());
204 fs::create_dir_all(&directory)?;
205 self.run(
206 vec![
207 "release".to_owned(),
208 "download".to_owned(),
209 manifest.release.tag.to_string(),
210 "--pattern".to_owned(),
211 name.clone(),
212 "--dir".to_owned(),
213 directory.display().to_string(),
214 "--repo".to_owned(),
215 self.repository.clone(),
216 ],
217 "download GitHub Release asset",
218 )?;
219 let actual_path = directory.join(&name);
220 let actual = sha256(&actual_path)?;
221 if actual != expected.sha256 {
222 bail!(
223 "remote asset digest mismatch for {name}: expected {}, found {actual}",
224 expected.sha256
225 );
226 }
227 verified.push(name);
228 }
229 Ok(verified)
230 }
231
232 fn download(&self, tag: &str, name: &str, directory: &Path) -> Result<PathBuf> {
233 fs::create_dir_all(directory)?;
234 self.run(
235 vec![
236 "release".to_owned(),
237 "download".to_owned(),
238 tag.to_owned(),
239 "--pattern".to_owned(),
240 name.to_owned(),
241 "--dir".to_owned(),
242 directory.display().to_string(),
243 "--repo".to_owned(),
244 self.repository.clone(),
245 ],
246 "download GitHub Release asset",
247 )?;
248 Ok(directory.join(name))
249 }
250
251 fn run(&self, arguments: Vec<String>, description: &str) -> Result<()> {
252 let request = CommandRequest::new("gh", arguments, &self.root);
253 self.runner
254 .execute(&request)?
255 .require_success(description)?;
256 Ok(())
257 }
258}
259
260impl Publisher for GithubReleasePublisher {
261 fn inspect(&self, plan: &TargetPlan) -> Result<PublicationState> {
262 let info = self.release_info(plan)?;
263 Ok(self.inspect_info(plan, info.as_ref()))
264 }
265
266 fn publish(&self, manifest: &ArtifactManifest) -> Result<PublicationReceipt> {
267 if !manifest.release.tag_already_sealed {
268 bail!(
269 "release {} is not sealed on the remote",
270 manifest.release.tag
271 );
272 }
273 if manifest.publisher != self.name {
274 bail!(
275 "artifact manifest belongs to publisher `{}`",
276 manifest.publisher
277 );
278 }
279 validate_manifest(manifest)?;
280 let plan = Self::plan_for_manifest(manifest);
281 let info = self.release_info(&plan)?;
282 let state = self.inspect_info(&plan, info.as_ref());
283 let write_result = match state {
284 PublicationState::Complete => {
285 self.verify(manifest)?;
286 return Ok(receipt(manifest, &self.name, true));
287 }
288 PublicationState::Invalid { reason } => bail!("invalid publication: {reason}"),
289 PublicationState::Partial { present, missing } => {
290 self.verify_selected(manifest, present)?;
291 self.upload_missing(manifest, &missing)
292 }
293 PublicationState::Absent if info.is_some() => {
294 let missing = expected_asset_names(&plan);
295 self.upload_missing(manifest, &missing)
296 }
297 PublicationState::Absent => self.create(manifest),
298 };
299 let state_after_write = self.inspect(&plan);
300 match (write_result, state_after_write) {
301 (_, Ok(PublicationState::Complete)) => {}
302 (Err(write_error), Ok(state)) => {
303 return Err(write_error.context(format!(
304 "GitHub Release write failed and remote state is {state:?}"
305 )));
306 }
307 (Err(write_error), Err(inspect_error)) => {
308 return Err(write_error.context(format!(
309 "GitHub Release write failed; remote reconciliation also failed: {inspect_error:#}"
310 )));
311 }
312 (Ok(()), Ok(state)) => {
313 bail!("GitHub Release is not complete after publish: {state:?}");
314 }
315 (Ok(()), Err(inspect_error)) => return Err(inspect_error),
316 }
317 self.verify(manifest)?;
318 Ok(receipt(manifest, &self.name, false))
319 }
320
321 fn verify(&self, manifest: &ArtifactManifest) -> Result<VerificationReport> {
322 let plan = Self::plan_for_manifest(manifest);
323 match self.inspect(&plan)? {
324 PublicationState::Complete => {}
325 state => bail!("cannot verify incomplete GitHub Release: {state:?}"),
326 }
327 let names = expected_asset_names(&plan);
328 let artifacts = self.verify_selected(manifest, names)?;
329 Ok(VerificationReport {
330 target: manifest.target.clone(),
331 verified: true,
332 artifacts,
333 })
334 }
335
336 fn verify_existing(&self, plan: &TargetPlan) -> Result<VerificationReport> {
337 match self.inspect(plan)? {
338 PublicationState::Complete => {}
339 state => bail!("cannot verify incomplete GitHub Release: {state:?}"),
340 }
341 let names = expected_asset_names(plan);
342 let checksum_name = names
343 .iter()
344 .find(|name| name.ends_with(".sha256"))
345 .context("GitHub Release target has no SHA-256 checksum asset")?;
346 let archive_name = checksum_name.trim_end_matches(".sha256");
347 if !names.iter().any(|name| name == archive_name) {
348 bail!("checksum asset `{checksum_name}` has no matching archive asset");
349 }
350 let temporary = tempfile::tempdir().context("failed to create verification directory")?;
351 let archive = self.download(
352 &plan.release.tag.to_string(),
353 archive_name,
354 &temporary.path().join("archive"),
355 )?;
356 let checksum = self.download(
357 &plan.release.tag.to_string(),
358 checksum_name,
359 &temporary.path().join("checksum"),
360 )?;
361 let checksum_source = fs::read_to_string(&checksum)
362 .with_context(|| format!("failed to read {}", checksum.display()))?;
363 let expected_line = checksum_source
364 .strip_suffix('\n')
365 .unwrap_or(&checksum_source);
366 let (expected_digest, expected_name) = expected_line
367 .split_once(" ")
368 .context("invalid SHA-256 checksum asset")?;
369 if expected_name != archive_name {
370 bail!("checksum asset names `{expected_name}`, expected `{archive_name}`");
371 }
372 let actual_digest = sha256(&archive)?;
373 if expected_digest != actual_digest {
374 bail!(
375 "remote archive digest mismatch for {archive_name}: checksum declares {expected_digest}, found {actual_digest}"
376 );
377 }
378 Ok(VerificationReport {
379 target: plan.name.clone(),
380 verified: true,
381 artifacts: names,
382 })
383 }
384}
385
386fn expected_asset_names(plan: &TargetPlan) -> Vec<String> {
387 plan.artifacts
388 .iter()
389 .filter_map(|identity| match identity {
390 ArtifactIdentity::GithubReleaseAsset { name } => Some(name.clone()),
391 _ => None,
392 })
393 .collect()
394}
395
396fn artifact_by_name<'a>(
397 manifest: &'a ArtifactManifest,
398 name: &str,
399) -> Result<&'a PreparedArtifact> {
400 manifest
401 .artifacts
402 .iter()
403 .find(|artifact| {
404 matches!(
405 &artifact.identity,
406 ArtifactIdentity::GithubReleaseAsset { name: artifact_name }
407 if artifact_name == name
408 )
409 })
410 .with_context(|| format!("artifact manifest does not contain `{name}`"))
411}
412
413fn sha256(path: &Path) -> Result<String> {
414 let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
415 Ok(hex::encode(Sha256::digest(bytes)))
416}