Skip to main content

yah_qed/provider/
github_release.rs

1//! GitHub Release publisher adapter (R509-F7).
2//!
3//! Creates (or updates) a GitHub Release for a tag and uploads the run's
4//! produced artifacts as release assets. This is the **native** path to GitHub
5//! Releases — now the only one, since W224/R533-T7 retired the GHA `gh-release`
6//! workflow override — so a non-GHA pipeline can tag-and-upload directly. It's
7//! also how the `oss/` crates.io mirror releases publish their binaries.
8//!
9//! Driven through the `gh` CLI rather than a hand-rolled REST client: `gh` is
10//! ubiquitous in CI, already this workspace's GitHub surface, and handles auth,
11//! retries, and the two-step asset upload (`POST /releases` then the
12//! `uploads.github.com` multipart) — so the adapter adds **no new dependency**
13//! (same subprocess pattern as `notarize`/`testflight`). The `gh` binary is
14//! overridable; `GITHUB_TOKEN` is passed via the `GH_TOKEN` env var.
15//!
16//! A GitHub Release is a *ship*: [`ProviderReport::produced`] stays empty and
17//! the release `html_url` is returned in [`ProviderReport::published`].
18//!
19//! ## Idempotency
20//!
21//! `dispatch` is find-or-create by tag: if a release already exists for the tag
22//! it uploads the assets with `--clobber` (re-runs replace same-named assets);
23//! otherwise it creates the release. Re-running a release is safe.
24//!
25//! ## Credentials
26//!
27//! - `GITHUB_TOKEN` — a repo-scoped PAT or installation token, passed to `gh`
28//!   as `GH_TOKEN`. Never logged.
29//!
30//! ## Dry run
31//!
32//! `ctx.dry_run` confirms `GITHUB_TOKEN` resolves and the selected asset files
33//! exist, then reports `would create release <owner/repo>@<tag> with N assets`
34//! — spawning no `gh` and mutating nothing.
35
36use 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
45/// Credential slot: the GitHub token (passed to `gh` as `GH_TOKEN`).
46const SLOT_TOKEN: &str = "GITHUB_TOKEN";
47
48/// The `with = { … }` config block for a `github-release` outcome.
49#[derive(Debug, Clone, Default, Deserialize)]
50#[serde(default)]
51struct GithubReleaseConfig {
52    /// `owner/repo` slug the release belongs to. Required for a live publish.
53    repo: String,
54    /// Git tag to release. Defaults to `v<version>` when unset.
55    tag: Option<String>,
56    /// Target commitish (branch / SHA) for the tag if it doesn't exist yet.
57    target_commitish: Option<String>,
58    /// Create the release as a draft.
59    draft: bool,
60    /// Mark the release as a prerelease.
61    prerelease: bool,
62    /// Release title. Defaults to the tag.
63    title: Option<String>,
64    /// Inline release-notes body. Ignored when `notes_file` is set.
65    notes: Option<String>,
66    /// Path to a release-notes file (`--notes-file`). Wins over `notes`.
67    notes_file: Option<String>,
68    /// Binary-name / basename globs selecting which produced artifacts to
69    /// attach. Empty → attach every produced artifact (a notes-only release is
70    /// fine — zero assets is allowed).
71    artifacts: Vec<String>,
72}
73
74/// `github-release` — native GitHub Release create + asset upload. See module
75/// docs.
76#[derive(Debug, Default)]
77pub struct GithubReleaseProvider {
78    /// `gh` binary; overridable for tests / non-default installs.
79    gh_bin: Option<String>,
80}
81
82impl GithubReleaseProvider {
83    /// Path to the `gh` binary (defaults to `gh` on `PATH`).
84    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        // Every selected asset must exist on disk before we try to attach it.
109        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
153/// Deserialize the opaque `with` blob into [`GithubReleaseConfig`].
154fn 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
162/// Human label for the target repo, or a placeholder when unset (dry-run only).
163fn 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
171/// Select the assets to attach: the config globs over the produced artifacts,
172/// or — when no globs are given — every produced artifact. Unlike the other
173/// adapters, zero assets is allowed (a notes-only release), but a glob that
174/// matches nothing is a config error.
175fn 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
196/// Whether a release already exists for `tag` (`gh release view` exits non-zero
197/// when it doesn't).
198async 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
213/// Create the release with `gh release create`, attaching any assets inline.
214async 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
256/// Upload assets to an existing release with `--clobber` (idempotent re-run).
257async 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
284/// Resolve the release `html_url` via `gh release view --json url`.
285async 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
302/// A config glob matches an artifact when it globs the `binary` field or the
303/// file basename.
304fn artifact_matches(art: &ProducedArtifact, glob: &str) -> bool {
305    glob_match(glob, &art.binary) || glob_match(glob, basename(&art.path))
306}
307
308/// File basename of a path (the part after the last `/`).
309fn basename(path: &str) -> &str {
310    Path::new(path)
311        .file_name()
312        .and_then(|s| s.to_str())
313        .unwrap_or(path)
314}
315
316/// Minimal glob matcher — `*` matches any run (including empty); every other
317/// char is literal.
318fn 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        // Artifact path points at a file that was never written.
477        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}