1use std::path::Path;
37
38use async_trait::async_trait;
39use serde::Deserialize;
40
41use crate::provider::{ProviderContext, ProviderReport, ReleaseProvider};
42use crate::runner::RunnerError;
43use crate::types::ProducedArtifact;
44
45const SLOT_TOKEN: &str = "GITHUB_TOKEN";
47
48#[derive(Debug, Clone, Default, Deserialize)]
50#[serde(default)]
51struct GithubReleaseConfig {
52 repo: String,
54 tag: Option<String>,
56 target_commitish: Option<String>,
58 draft: bool,
60 prerelease: bool,
62 title: Option<String>,
64 notes: Option<String>,
66 notes_file: Option<String>,
68 artifacts: Vec<String>,
72}
73
74#[derive(Debug, Default)]
77pub struct GithubReleaseProvider {
78 gh_bin: Option<String>,
80}
81
82impl GithubReleaseProvider {
83 fn gh(&self) -> &str {
85 self.gh_bin.as_deref().unwrap_or("gh")
86 }
87}
88
89#[async_trait]
90impl ReleaseProvider for GithubReleaseProvider {
91 fn name(&self) -> &str {
92 "github-release"
93 }
94
95 fn required_slots(&self) -> Vec<&str> {
96 vec![SLOT_TOKEN]
97 }
98
99 async fn dispatch(&self, ctx: &ProviderContext<'_>) -> Result<ProviderReport, RunnerError> {
100 let cfg: GithubReleaseConfig = parse_config(ctx.config)?;
101 let token = ctx.require_secret(SLOT_TOKEN)?;
102 let tag = cfg
103 .tag
104 .clone()
105 .unwrap_or_else(|| format!("v{}", ctx.version));
106
107 let assets = select_assets(ctx.artifacts, &cfg.artifacts)?;
108 for a in &assets {
110 if !Path::new(&a.path).exists() {
111 return Err(RunnerError::Outcome(format!(
112 "github-release: asset {} does not exist",
113 a.path
114 )));
115 }
116 }
117
118 let repo = repo_label(&cfg);
119
120 if ctx.dry_run {
121 return Ok(ProviderReport::action(format!(
122 "would create release {repo}@{tag} with {} asset(s)",
123 assets.len(),
124 )));
125 }
126
127 if cfg.repo.is_empty() {
128 return Err(RunnerError::Outcome(
129 "github-release: `with.repo` (owner/repo) is required for a live publish".into(),
130 ));
131 }
132
133 let asset_paths: Vec<&str> = assets.iter().map(|a| a.path.as_str()).collect();
134 let gh = self.gh();
135
136 if release_exists(gh, &token, &cfg.repo, &tag).await? {
137 if !asset_paths.is_empty() {
138 upload_assets(gh, &token, &cfg.repo, &tag, &asset_paths).await?;
139 }
140 } else {
141 create_release(gh, &token, &cfg, &tag, &asset_paths).await?;
142 }
143
144 let url = release_url(gh, &token, &cfg.repo, &tag).await?;
145 Ok(ProviderReport {
146 actions: vec![format!("published release {repo}@{tag} ({} assets)", assets.len())],
147 produced: Vec::new(),
148 published: vec![url],
149 })
150 }
151}
152
153fn parse_config(value: &serde_json::Value) -> Result<GithubReleaseConfig, RunnerError> {
155 if value.is_null() {
156 return Ok(GithubReleaseConfig::default());
157 }
158 serde_json::from_value(value.clone())
159 .map_err(|e| RunnerError::Outcome(format!("github-release: invalid `with` config: {e}")))
160}
161
162fn repo_label(cfg: &GithubReleaseConfig) -> String {
164 if cfg.repo.is_empty() {
165 "<owner/repo>".to_string()
166 } else {
167 cfg.repo.clone()
168 }
169}
170
171fn select_assets(
176 artifacts: &[ProducedArtifact],
177 globs: &[String],
178) -> Result<Vec<ProducedArtifact>, RunnerError> {
179 if globs.is_empty() {
180 return Ok(artifacts.to_vec());
181 }
182 let selected: Vec<ProducedArtifact> = artifacts
183 .iter()
184 .filter(|a| globs.iter().any(|g| artifact_matches(a, g)))
185 .cloned()
186 .collect();
187 if selected.is_empty() {
188 return Err(RunnerError::Outcome(format!(
189 "github-release: no artifact matched config globs {globs:?} (of {} produced)",
190 artifacts.len()
191 )));
192 }
193 Ok(selected)
194}
195
196async fn release_exists(
199 gh: &str,
200 token: &str,
201 repo: &str,
202 tag: &str,
203) -> Result<bool, RunnerError> {
204 let out = tokio::process::Command::new(gh)
205 .args(["release", "view", tag, "--repo", repo])
206 .env("GH_TOKEN", token)
207 .output()
208 .await
209 .map_err(|e| RunnerError::Outcome(format!("github-release: spawning `{gh} release view`: {e}")))?;
210 Ok(out.status.success())
211}
212
213async fn create_release(
215 gh: &str,
216 token: &str,
217 cfg: &GithubReleaseConfig,
218 tag: &str,
219 assets: &[&str],
220) -> Result<(), RunnerError> {
221 let mut cmd = tokio::process::Command::new(gh);
222 cmd.args(["release", "create", tag, "--repo", &cfg.repo]);
223 cmd.arg("--title").arg(cfg.title.as_deref().unwrap_or(tag));
224 if let Some(file) = &cfg.notes_file {
225 cmd.arg("--notes-file").arg(file);
226 } else {
227 cmd.arg("--notes").arg(cfg.notes.as_deref().unwrap_or(""));
228 }
229 if let Some(target) = &cfg.target_commitish {
230 cmd.arg("--target").arg(target);
231 }
232 if cfg.draft {
233 cmd.arg("--draft");
234 }
235 if cfg.prerelease {
236 cmd.arg("--prerelease");
237 }
238 for a in assets {
239 cmd.arg(a);
240 }
241 let out = cmd
242 .env("GH_TOKEN", token)
243 .output()
244 .await
245 .map_err(|e| RunnerError::Outcome(format!("github-release: spawning `{gh} release create`: {e}")))?;
246 if !out.status.success() {
247 return Err(RunnerError::Outcome(format!(
248 "github-release: `gh release create {tag}` failed (status {}): {}",
249 out.status,
250 String::from_utf8_lossy(&out.stderr).trim(),
251 )));
252 }
253 Ok(())
254}
255
256async fn upload_assets(
258 gh: &str,
259 token: &str,
260 repo: &str,
261 tag: &str,
262 assets: &[&str],
263) -> Result<(), RunnerError> {
264 let mut cmd = tokio::process::Command::new(gh);
265 cmd.args(["release", "upload", tag, "--repo", repo, "--clobber"]);
266 for a in assets {
267 cmd.arg(a);
268 }
269 let out = cmd
270 .env("GH_TOKEN", token)
271 .output()
272 .await
273 .map_err(|e| RunnerError::Outcome(format!("github-release: spawning `{gh} release upload`: {e}")))?;
274 if !out.status.success() {
275 return Err(RunnerError::Outcome(format!(
276 "github-release: `gh release upload {tag}` failed (status {}): {}",
277 out.status,
278 String::from_utf8_lossy(&out.stderr).trim(),
279 )));
280 }
281 Ok(())
282}
283
284async fn release_url(gh: &str, token: &str, repo: &str, tag: &str) -> Result<String, RunnerError> {
286 let out = tokio::process::Command::new(gh)
287 .args(["release", "view", tag, "--repo", repo, "--json", "url", "-q", ".url"])
288 .env("GH_TOKEN", token)
289 .output()
290 .await
291 .map_err(|e| RunnerError::Outcome(format!("github-release: spawning `{gh} release view`: {e}")))?;
292 if !out.status.success() {
293 return Err(RunnerError::Outcome(format!(
294 "github-release: resolving release URL for {tag} failed (status {}): {}",
295 out.status,
296 String::from_utf8_lossy(&out.stderr).trim(),
297 )));
298 }
299 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
300}
301
302fn artifact_matches(art: &ProducedArtifact, glob: &str) -> bool {
305 glob_match(glob, &art.binary) || glob_match(glob, basename(&art.path))
306}
307
308fn basename(path: &str) -> &str {
310 Path::new(path)
311 .file_name()
312 .and_then(|s| s.to_str())
313 .unwrap_or(path)
314}
315
316fn glob_match(pattern: &str, text: &str) -> bool {
319 let (p, t): (Vec<char>, Vec<char>) = (pattern.chars().collect(), text.chars().collect());
320 let (mut pi, mut ti) = (0usize, 0usize);
321 let (mut star, mut star_t): (Option<usize>, usize) = (None, 0);
322 while ti < t.len() {
323 if pi < p.len() && (p[pi] == t[ti]) {
324 pi += 1;
325 ti += 1;
326 } else if pi < p.len() && p[pi] == '*' {
327 star = Some(pi);
328 star_t = ti;
329 pi += 1;
330 } else if let Some(s) = star {
331 pi = s + 1;
332 star_t += 1;
333 ti = star_t;
334 } else {
335 return false;
336 }
337 }
338 while pi < p.len() && p[pi] == '*' {
339 pi += 1;
340 }
341 pi == p.len()
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use crate::provider::MapSecrets;
348 use std::collections::BTreeMap;
349
350 fn fixture_asset(dir: &Path, name: &str) -> ProducedArtifact {
351 let p = dir.join(name);
352 std::fs::write(&p, b"binary").unwrap();
353 ProducedArtifact {
354 binary: name.split('.').next().unwrap_or(name).into(),
355 path: p.to_str().unwrap().into(),
356 triple: Some("linux-x86_64".into()),
357 }
358 }
359
360 fn full_secrets() -> MapSecrets {
361 let mut m = BTreeMap::new();
362 m.insert(SLOT_TOKEN.into(), "ghp_xxx".into());
363 MapSecrets(m)
364 }
365
366 fn ctx<'a>(
367 secrets: &'a dyn crate::provider::SecretSource,
368 work: &'a Path,
369 cfg: &'a serde_json::Value,
370 artifacts: &'a [ProducedArtifact],
371 dry_run: bool,
372 ) -> ProviderContext<'a> {
373 ProviderContext {
374 version: "1.2.3",
375 artifacts,
376 base_url: None,
377 config: cfg,
378 work_dir: work,
379 secrets,
380 dry_run,
381 }
382 }
383
384 #[test]
385 fn declares_github_token_slot() {
386 let p = GithubReleaseProvider::default();
387 assert_eq!(p.name(), "github-release");
388 assert_eq!(p.required_slots(), vec![SLOT_TOKEN]);
389 }
390
391 #[tokio::test]
392 async fn dry_run_reports_release_without_spawning_gh() {
393 let work = tempfile::tempdir().unwrap();
394 let a = fixture_asset(work.path(), "yah-linux.tar.gz");
395 let b = fixture_asset(work.path(), "yah-macos.tar.gz");
396 let secrets = full_secrets();
397 let cfg = serde_json::json!({ "repo": "yah-ai/yah" });
398 let report = GithubReleaseProvider::default()
399 .dispatch(&ctx(&secrets, work.path(), &cfg, &[a, b], true))
400 .await
401 .unwrap();
402 assert_eq!(report.actions.len(), 1);
403 assert!(report.actions[0].contains("would create release yah-ai/yah@v1.2.3"));
404 assert!(report.actions[0].contains("with 2 asset(s)"));
405 assert!(report.published.is_empty());
406 }
407
408 #[tokio::test]
409 async fn tag_defaults_to_v_version_and_config_overrides() {
410 let work = tempfile::tempdir().unwrap();
411 let a = fixture_asset(work.path(), "x.tar.gz");
412 let secrets = full_secrets();
413 let cfg = serde_json::json!({ "repo": "o/r", "tag": "release-7" });
414 let report = GithubReleaseProvider::default()
415 .dispatch(&ctx(&secrets, work.path(), &cfg, std::slice::from_ref(&a), true))
416 .await
417 .unwrap();
418 assert!(report.actions[0].contains("o/r@release-7"));
419 }
420
421 #[tokio::test]
422 async fn dry_run_allows_zero_assets_notes_only_release() {
423 let work = tempfile::tempdir().unwrap();
424 let secrets = full_secrets();
425 let cfg = serde_json::json!({ "repo": "o/r" });
426 let report = GithubReleaseProvider::default()
427 .dispatch(&ctx(&secrets, work.path(), &cfg, &[], true))
428 .await
429 .unwrap();
430 assert!(report.actions[0].contains("with 0 asset(s)"));
431 }
432
433 #[tokio::test]
434 async fn config_glob_selects_subset_of_assets() {
435 let work = tempfile::tempdir().unwrap();
436 let a = fixture_asset(work.path(), "yah-linux.tar.gz");
437 let b = fixture_asset(work.path(), "desktop.dmg");
438 let secrets = full_secrets();
439 let cfg = serde_json::json!({ "repo": "o/r", "artifacts": ["*.tar.gz"] });
440 let report = GithubReleaseProvider::default()
441 .dispatch(&ctx(&secrets, work.path(), &cfg, &[a, b], true))
442 .await
443 .unwrap();
444 assert!(report.actions[0].contains("with 1 asset(s)"));
445 }
446
447 #[tokio::test]
448 async fn glob_with_no_match_is_an_error() {
449 let work = tempfile::tempdir().unwrap();
450 let a = fixture_asset(work.path(), "x.dmg");
451 let secrets = full_secrets();
452 let cfg = serde_json::json!({ "repo": "o/r", "artifacts": ["*.exe"] });
453 let err = GithubReleaseProvider::default()
454 .dispatch(&ctx(&secrets, work.path(), &cfg, std::slice::from_ref(&a), true))
455 .await
456 .unwrap_err();
457 assert!(format!("{err}").contains("no artifact matched"));
458 }
459
460 #[tokio::test]
461 async fn missing_token_is_typed_error() {
462 let work = tempfile::tempdir().unwrap();
463 let a = fixture_asset(work.path(), "x.tar.gz");
464 let secrets = MapSecrets::default();
465 let cfg = serde_json::json!({ "repo": "o/r" });
466 let err = GithubReleaseProvider::default()
467 .dispatch(&ctx(&secrets, work.path(), &cfg, std::slice::from_ref(&a), true))
468 .await
469 .unwrap_err();
470 assert!(format!("{err}").contains(SLOT_TOKEN));
471 }
472
473 #[tokio::test]
474 async fn missing_asset_file_is_an_error() {
475 let work = tempfile::tempdir().unwrap();
476 let ghost = ProducedArtifact {
478 binary: "x".into(),
479 path: work.path().join("missing.tar.gz").to_str().unwrap().into(),
480 triple: None,
481 };
482 let secrets = full_secrets();
483 let cfg = serde_json::json!({ "repo": "o/r" });
484 let err = GithubReleaseProvider::default()
485 .dispatch(&ctx(&secrets, work.path(), &cfg, std::slice::from_ref(&ghost), true))
486 .await
487 .unwrap_err();
488 assert!(format!("{err}").contains("does not exist"));
489 }
490
491 #[test]
492 fn glob_match_supports_star() {
493 assert!(glob_match("*.tar.gz", "yah-linux.tar.gz"));
494 assert!(glob_match("yah-*", "yah-linux.tar.gz"));
495 assert!(!glob_match("*.exe", "x.dmg"));
496 }
497}