Skip to main content

release_tool/publisher/
github_release.rs

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}