use super::{
PublicationReceipt, PublicationState, Publisher, VerificationReport, receipt, validate_manifest,
};
use crate::command::{CommandRequest, CommandRunner};
use crate::config::PublisherConfig;
use crate::domain::{ArtifactIdentity, ArtifactManifest, PreparedArtifact, TargetPlan};
use crate::lifecycle::expand;
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub struct GithubReleasePublisher {
root: PathBuf,
repository: String,
name: String,
title: String,
prerelease: bool,
runner: Arc<dyn CommandRunner>,
}
#[derive(Clone, Debug, Deserialize)]
struct ReleaseInfo {
tag_name: String,
prerelease: bool,
#[serde(default)]
draft: bool,
assets: Vec<ReleaseAsset>,
}
#[derive(Clone, Debug, Deserialize)]
struct ReleaseAsset {
name: String,
}
impl GithubReleasePublisher {
pub fn new(
root: impl Into<PathBuf>,
repository: &str,
name: &str,
config: &PublisherConfig,
runner: Arc<dyn CommandRunner>,
) -> Result<Self> {
let PublisherConfig::GithubRelease { title, prerelease } = config else {
bail!("publisher `{name}` is not a github_release publisher");
};
Ok(Self {
root: root.into(),
repository: repository.to_owned(),
name: name.to_owned(),
title: title.clone(),
prerelease: *prerelease,
runner,
})
}
fn release_info(&self, plan: &TargetPlan) -> Result<Option<ReleaseInfo>> {
let endpoint = format!(
"repos/{}/releases/tags/{}",
self.repository, plan.release.tag
);
let request = CommandRequest::new("gh", ["api", endpoint.as_str()], &self.root);
let result = self.runner.execute(&request)?;
if result.status != 0 {
if result.stderr.contains("404") || result.stderr.contains("Not Found") {
return Ok(None);
}
return Err(result
.require_success("query GitHub Release")
.expect_err("non-zero command must fail"));
}
let info: ReleaseInfo =
serde_json::from_str(&result.stdout).context("invalid GitHub Release response")?;
Ok(Some(info))
}
fn plan_for_manifest(manifest: &ArtifactManifest) -> TargetPlan {
TargetPlan {
name: manifest.target.clone(),
publisher: manifest.publisher.clone(),
release: manifest.release.clone(),
artifacts: manifest
.artifacts
.iter()
.map(|artifact| artifact.identity.clone())
.collect(),
}
}
fn inspect_info(&self, plan: &TargetPlan, info: Option<&ReleaseInfo>) -> PublicationState {
let Some(info) = info else {
return PublicationState::Absent;
};
if info.tag_name != plan.release.tag.to_string() {
return PublicationState::Invalid {
reason: format!(
"GitHub Release tag is {}, expected {}",
info.tag_name, plan.release.tag
),
};
}
if info.draft {
return PublicationState::Invalid {
reason: "GitHub Release is still a draft; automatic publication is unsafe"
.to_owned(),
};
}
if info.prerelease != self.prerelease {
return PublicationState::Invalid {
reason: format!(
"GitHub Release prerelease is {}, expected {}",
info.prerelease, self.prerelease
),
};
}
let remote: HashSet<_> = info
.assets
.iter()
.map(|asset| asset.name.as_str())
.collect();
if remote.len() != info.assets.len() {
return PublicationState::Invalid {
reason: "GitHub Release contains duplicate asset names".to_owned(),
};
}
let expected = expected_asset_names(plan);
let present: Vec<_> = expected
.iter()
.filter(|name| remote.contains(name.as_str()))
.cloned()
.collect();
let missing: Vec<_> = expected
.iter()
.filter(|name| !remote.contains(name.as_str()))
.cloned()
.collect();
if present.is_empty() {
PublicationState::Absent
} else if missing.is_empty() {
PublicationState::Complete
} else {
PublicationState::Partial { present, missing }
}
}
fn upload_missing(&self, manifest: &ArtifactManifest, missing: &[String]) -> Result<()> {
let mut arguments = vec![
"release".to_owned(),
"upload".to_owned(),
manifest.release.tag.to_string(),
];
for name in missing {
let artifact = artifact_by_name(manifest, name)?;
arguments.push(artifact.path.display().to_string());
}
arguments.extend(["--repo".to_owned(), self.repository.clone()]);
self.run(arguments, "upload GitHub Release assets")?;
Ok(())
}
fn create(&self, manifest: &ArtifactManifest) -> Result<()> {
let version = manifest.release.tag.to_string();
let title = expand(&self.title, &version, None)?;
let mut arguments = vec!["release".to_owned(), "create".to_owned(), version];
for artifact in &manifest.artifacts {
arguments.push(artifact.path.display().to_string());
}
arguments.extend([
"--repo".to_owned(),
self.repository.clone(),
"--verify-tag".to_owned(),
"--target".to_owned(),
manifest.release.commit.clone(),
"--title".to_owned(),
title,
"--notes".to_owned(),
format!(
"Commit: `{}`\nTool: `release-tool/{}`",
manifest.release.commit,
env!("CARGO_PKG_VERSION")
),
"--latest=false".to_owned(),
]);
if self.prerelease {
arguments.push("--prerelease".to_owned());
}
self.run(arguments, "create GitHub Release")?;
Ok(())
}
fn verify_selected(
&self,
manifest: &ArtifactManifest,
names: impl IntoIterator<Item = String>,
) -> Result<Vec<String>> {
let temporary = tempfile::tempdir().context("failed to create verification directory")?;
let mut verified = Vec::new();
for name in names {
let expected = artifact_by_name(manifest, &name)?;
let directory = temporary.path().join(verified.len().to_string());
fs::create_dir_all(&directory)?;
self.run(
vec![
"release".to_owned(),
"download".to_owned(),
manifest.release.tag.to_string(),
"--pattern".to_owned(),
name.clone(),
"--dir".to_owned(),
directory.display().to_string(),
"--repo".to_owned(),
self.repository.clone(),
],
"download GitHub Release asset",
)?;
let actual_path = directory.join(&name);
let actual = sha256(&actual_path)?;
if actual != expected.sha256 {
bail!(
"remote asset digest mismatch for {name}: expected {}, found {actual}",
expected.sha256
);
}
verified.push(name);
}
Ok(verified)
}
fn download(&self, tag: &str, name: &str, directory: &Path) -> Result<PathBuf> {
fs::create_dir_all(directory)?;
self.run(
vec![
"release".to_owned(),
"download".to_owned(),
tag.to_owned(),
"--pattern".to_owned(),
name.to_owned(),
"--dir".to_owned(),
directory.display().to_string(),
"--repo".to_owned(),
self.repository.clone(),
],
"download GitHub Release asset",
)?;
Ok(directory.join(name))
}
fn run(&self, arguments: Vec<String>, description: &str) -> Result<()> {
let request = CommandRequest::new("gh", arguments, &self.root);
self.runner
.execute(&request)?
.require_success(description)?;
Ok(())
}
}
impl Publisher for GithubReleasePublisher {
fn inspect(&self, plan: &TargetPlan) -> Result<PublicationState> {
let info = self.release_info(plan)?;
Ok(self.inspect_info(plan, info.as_ref()))
}
fn publish(&self, manifest: &ArtifactManifest) -> Result<PublicationReceipt> {
if !manifest.release.tag_already_sealed {
bail!(
"release {} is not sealed on the remote",
manifest.release.tag
);
}
if manifest.publisher != self.name {
bail!(
"artifact manifest belongs to publisher `{}`",
manifest.publisher
);
}
validate_manifest(manifest)?;
let plan = Self::plan_for_manifest(manifest);
let info = self.release_info(&plan)?;
let state = self.inspect_info(&plan, info.as_ref());
let write_result = match state {
PublicationState::Complete => {
self.verify(manifest)?;
return Ok(receipt(manifest, &self.name, true));
}
PublicationState::Invalid { reason } => bail!("invalid publication: {reason}"),
PublicationState::Partial { present, missing } => {
self.verify_selected(manifest, present)?;
self.upload_missing(manifest, &missing)
}
PublicationState::Absent if info.is_some() => {
let missing = expected_asset_names(&plan);
self.upload_missing(manifest, &missing)
}
PublicationState::Absent => self.create(manifest),
};
let state_after_write = self.inspect(&plan);
match (write_result, state_after_write) {
(_, Ok(PublicationState::Complete)) => {}
(Err(write_error), Ok(state)) => {
return Err(write_error.context(format!(
"GitHub Release write failed and remote state is {state:?}"
)));
}
(Err(write_error), Err(inspect_error)) => {
return Err(write_error.context(format!(
"GitHub Release write failed; remote reconciliation also failed: {inspect_error:#}"
)));
}
(Ok(()), Ok(state)) => {
bail!("GitHub Release is not complete after publish: {state:?}");
}
(Ok(()), Err(inspect_error)) => return Err(inspect_error),
}
self.verify(manifest)?;
Ok(receipt(manifest, &self.name, false))
}
fn verify(&self, manifest: &ArtifactManifest) -> Result<VerificationReport> {
let plan = Self::plan_for_manifest(manifest);
match self.inspect(&plan)? {
PublicationState::Complete => {}
state => bail!("cannot verify incomplete GitHub Release: {state:?}"),
}
let names = expected_asset_names(&plan);
let artifacts = self.verify_selected(manifest, names)?;
Ok(VerificationReport {
target: manifest.target.clone(),
verified: true,
artifacts,
})
}
fn verify_existing(&self, plan: &TargetPlan) -> Result<VerificationReport> {
match self.inspect(plan)? {
PublicationState::Complete => {}
state => bail!("cannot verify incomplete GitHub Release: {state:?}"),
}
let names = expected_asset_names(plan);
let checksum_name = names
.iter()
.find(|name| name.ends_with(".sha256"))
.context("GitHub Release target has no SHA-256 checksum asset")?;
let archive_name = checksum_name.trim_end_matches(".sha256");
if !names.iter().any(|name| name == archive_name) {
bail!("checksum asset `{checksum_name}` has no matching archive asset");
}
let temporary = tempfile::tempdir().context("failed to create verification directory")?;
let archive = self.download(
&plan.release.tag.to_string(),
archive_name,
&temporary.path().join("archive"),
)?;
let checksum = self.download(
&plan.release.tag.to_string(),
checksum_name,
&temporary.path().join("checksum"),
)?;
let checksum_source = fs::read_to_string(&checksum)
.with_context(|| format!("failed to read {}", checksum.display()))?;
let expected_line = checksum_source
.strip_suffix('\n')
.unwrap_or(&checksum_source);
let (expected_digest, expected_name) = expected_line
.split_once(" ")
.context("invalid SHA-256 checksum asset")?;
if expected_name != archive_name {
bail!("checksum asset names `{expected_name}`, expected `{archive_name}`");
}
let actual_digest = sha256(&archive)?;
if expected_digest != actual_digest {
bail!(
"remote archive digest mismatch for {archive_name}: checksum declares {expected_digest}, found {actual_digest}"
);
}
Ok(VerificationReport {
target: plan.name.clone(),
verified: true,
artifacts: names,
})
}
}
fn expected_asset_names(plan: &TargetPlan) -> Vec<String> {
plan.artifacts
.iter()
.filter_map(|identity| match identity {
ArtifactIdentity::GithubReleaseAsset { name } => Some(name.clone()),
_ => None,
})
.collect()
}
fn artifact_by_name<'a>(
manifest: &'a ArtifactManifest,
name: &str,
) -> Result<&'a PreparedArtifact> {
manifest
.artifacts
.iter()
.find(|artifact| {
matches!(
&artifact.identity,
ArtifactIdentity::GithubReleaseAsset { name: artifact_name }
if artifact_name == name
)
})
.with_context(|| format!("artifact manifest does not contain `{name}`"))
}
fn sha256(path: &Path) -> Result<String> {
let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
Ok(hex::encode(Sha256::digest(bytes)))
}