Skip to main content

shipshape_core/release/
delegated.rs

1//! Read-only observation of GitHub Actions runs that own delegated publishes.
2//!
3//! The destination remains the final authority for delivery, but a GitHub-backed
4//! delegated target first resolves the exact tag-triggered workflow run. This keeps
5//! "still building" and "workflow failed" from being misreported as a missing
6//! destination. Non-GitHub delegated adapters do not use this observer.
7
8use std::fmt::Write as _;
9
10use serde::Deserialize;
11
12use crate::contract::schema::Adapter;
13use crate::protocol::reconcile::{DelegatedJobFailure, DelegatedRun, DelegatedRunStatus};
14
15use super::adapters::EffectCtx;
16
17/// Observe the workflow run associated with `v{version}` for a GitHub-backed
18/// delegated adapter. Returns `None` for adapters whose delegation is not owned by
19/// GitHub Actions.
20#[must_use]
21pub fn observe_github_run(
22    ctx: &EffectCtx<'_>,
23    adapter: Adapter,
24    version: &str,
25) -> Option<DelegatedRun> {
26    let workflow = match adapter {
27        Adapter::CargoDist => ".github/workflows/release.yml".to_string(),
28        Adapter::CargoPublishCi => match cargo_publish_workflow(ctx, version) {
29            Ok(path) => path,
30            Err(detail) => return Some(DelegatedRun::unknown(None, None, detail)),
31        },
32        _ => return None,
33    };
34    Some(observe_workflow(ctx, &workflow, version))
35}
36
37fn cargo_publish_workflow(ctx: &EffectCtx<'_>, version: &str) -> Result<String, String> {
38    let output = ctx
39        .runner
40        .run(
41            "git",
42            &[
43                "grep",
44                "-l",
45                "-e",
46                "cargo publish",
47                &format!("v{version}"),
48                "--",
49                ".github/workflows/*.yml",
50                ".github/workflows/*.yaml",
51            ],
52            ctx.repo_root,
53        )
54        .map_err(|error| format!("could not inspect tag-triggered workflows: {error}"))?;
55    if output.status != Some(0) && output.status != Some(1) {
56        return Err(format!(
57            "could not inspect tag-triggered workflows: {}",
58            output.stderr.trim()
59        ));
60    }
61    let mut candidates: Vec<String> = output
62        .stdout
63        .lines()
64        .map(str::trim)
65        .filter(|line| !line.is_empty())
66        .map(str::to_string)
67        .collect();
68    candidates.sort();
69    candidates.dedup();
70    match candidates.as_slice() {
71        [only] => Ok(only.clone()),
72        [] => Err(
73            "no tracked GitHub Actions workflow containing `cargo publish` could be resolved for the cargo-publish-ci target"
74                .to_string(),
75        ),
76        many => Err(format!(
77            "more than one GitHub Actions workflow contains `cargo publish`; cannot identify the delegated owner: {}",
78            many.join(", ")
79        )),
80    }
81}
82
83#[derive(Deserialize)]
84#[serde(rename_all = "camelCase")]
85struct RunRow {
86    database_id: u64,
87    status: String,
88    #[serde(default)]
89    conclusion: Option<String>,
90    head_branch: String,
91    head_sha: String,
92    url: String,
93}
94
95#[derive(Deserialize)]
96#[serde(rename_all = "camelCase")]
97struct RunView {
98    conclusion: String,
99    url: String,
100    jobs: Vec<JobRow>,
101}
102
103#[derive(Deserialize)]
104struct JobRow {
105    name: String,
106    status: String,
107    conclusion: String,
108}
109
110#[allow(clippy::too_many_lines)] // one linear fail-closed query/classification pipeline
111fn observe_workflow(ctx: &EffectCtx<'_>, workflow: &str, version: &str) -> DelegatedRun {
112    let tag = format!("v{version}");
113    let expected_sha = match ctx
114        .runner
115        .run("git", &["rev-list", "-n", "1", &tag], ctx.repo_root)
116    {
117        Ok(output) if output.status == Some(0) && !output.stdout.trim().is_empty() => {
118            output.stdout.trim().to_string()
119        }
120        Ok(output) => {
121            return DelegatedRun::unknown(
122                Some(workflow.to_string()),
123                None,
124                format!(
125                    "could not resolve commit for tag `{tag}`: {}",
126                    output.stderr.trim()
127                ),
128            )
129        }
130        Err(error) => {
131            return DelegatedRun::unknown(
132                Some(workflow.to_string()),
133                None,
134                format!("could not resolve commit for tag `{tag}`: {error}"),
135            )
136        }
137    };
138    let workflow_id = workflow.rsplit('/').next().unwrap_or(workflow);
139    let output = match ctx.runner.run(
140        "gh",
141        &[
142            "run",
143            "list",
144            "--workflow",
145            workflow_id,
146            "--branch",
147            &tag,
148            "--event",
149            "push",
150            "--json",
151            "databaseId,status,conclusion,headBranch,headSha,url",
152            "--limit",
153            "20",
154        ],
155        ctx.repo_root,
156    ) {
157        Ok(output) => output,
158        Err(error) => {
159            return DelegatedRun::unknown(
160                Some(workflow.to_string()),
161                None,
162                format!("could not query GitHub Actions runs for `{workflow}`: {error}"),
163            )
164        }
165    };
166    if output.status != Some(0) {
167        return DelegatedRun::unknown(
168            Some(workflow.to_string()),
169            None,
170            format!(
171                "could not query GitHub Actions runs for `{workflow}`: {}",
172                output.stderr.trim()
173            ),
174        );
175    }
176    let rows: Vec<RunRow> = match serde_json::from_str(&output.stdout) {
177        Ok(rows) => rows,
178        Err(error) => {
179            return DelegatedRun::unknown(
180                Some(workflow.to_string()),
181                None,
182                format!("GitHub Actions returned an unreadable run list for `{workflow}`: {error}"),
183            )
184        }
185    };
186    let mut matching = rows
187        .into_iter()
188        .filter(|run| run.head_branch == tag && run.head_sha == expected_sha);
189    let Some(run) = matching.next() else {
190        return DelegatedRun {
191            provider: "github-actions".to_string(),
192            workflow: Some(workflow.to_string()),
193            run_id: None,
194            url: None,
195            status: DelegatedRunStatus::Pending,
196            conclusion: None,
197            failed_jobs: Vec::new(),
198            detail: Some(format!(
199                "the `{workflow}` run for tag `{tag}` at commit `{expected_sha}` is not visible yet"
200            )),
201        };
202    };
203    if matching.next().is_some() {
204        return DelegatedRun::unknown(
205            Some(workflow.to_string()),
206            None,
207            format!(
208                "multiple `{workflow}` runs match tag `{tag}` at commit `{expected_sha}`; refusing to guess which run owns this release"
209            ),
210        );
211    }
212    classify_run(ctx, workflow, run)
213}
214
215#[allow(clippy::too_many_lines)] // keeps terminal run + job evidence assembled in one place
216fn classify_run(ctx: &EffectCtx<'_>, workflow: &str, run: RunRow) -> DelegatedRun {
217    if matches!(
218        run.status.as_str(),
219        "queued" | "in_progress" | "waiting" | "requested" | "pending"
220    ) {
221        return DelegatedRun {
222            provider: "github-actions".to_string(),
223            workflow: Some(workflow.to_string()),
224            run_id: Some(run.database_id),
225            url: Some(run.url),
226            status: DelegatedRunStatus::Pending,
227            conclusion: None,
228            failed_jobs: Vec::new(),
229            detail: Some(format!("the delegated workflow run is {}", run.status)),
230        };
231    }
232    if run.status != "completed" {
233        return DelegatedRun::unknown(
234            Some(workflow.to_string()),
235            Some(run.database_id),
236            format!(
237                "GitHub Actions returned unrecognized run status `{}`",
238                run.status
239            ),
240        );
241    }
242    if run.conclusion.as_deref() == Some("success") {
243        return DelegatedRun {
244            provider: "github-actions".to_string(),
245            workflow: Some(workflow.to_string()),
246            run_id: Some(run.database_id),
247            url: Some(run.url),
248            status: DelegatedRunStatus::Success,
249            conclusion: run.conclusion,
250            failed_jobs: Vec::new(),
251            detail: None,
252        };
253    }
254    let Some(list_conclusion) = run.conclusion.clone() else {
255        return DelegatedRun::unknown(
256            Some(workflow.to_string()),
257            Some(run.database_id),
258            "GitHub Actions reported a completed run without a conclusion".to_string(),
259        );
260    };
261    let id = run.database_id.to_string();
262    let view = ctx.runner.run(
263        "gh",
264        &["run", "view", &id, "--json", "status,conclusion,url,jobs"],
265        ctx.repo_root,
266    );
267    let (conclusion, url, failed_jobs, extra) = match view {
268        Ok(output) if output.status == Some(0) => {
269            match serde_json::from_str::<RunView>(&output.stdout) {
270                Ok(view) => {
271                    if view.conclusion == "success" {
272                        return DelegatedRun {
273                            provider: "github-actions".to_string(),
274                            workflow: Some(workflow.to_string()),
275                            run_id: Some(run.database_id),
276                            url: Some(view.url),
277                            status: DelegatedRunStatus::Success,
278                            conclusion: Some(view.conclusion),
279                            failed_jobs: Vec::new(),
280                            detail: None,
281                        };
282                    }
283                    let jobs = view
284                        .jobs
285                        .into_iter()
286                        .filter(|job| {
287                            job.status == "completed"
288                                && !matches!(
289                                    job.conclusion.as_str(),
290                                    "success" | "skipped" | "neutral"
291                                )
292                        })
293                        .map(|job| DelegatedJobFailure {
294                            name: job.name,
295                            conclusion: job.conclusion,
296                        })
297                        .collect();
298                    (view.conclusion, Some(view.url), jobs, None)
299                }
300                Err(error) => (
301                    list_conclusion.clone(),
302                    Some(run.url.clone()),
303                    Vec::new(),
304                    Some(format!("could not parse the failed run's jobs: {error}")),
305                ),
306            }
307        }
308        Ok(output) => (
309            list_conclusion.clone(),
310            Some(run.url.clone()),
311            Vec::new(),
312            Some(format!(
313                "could not inspect the failed run's jobs: {}",
314                output.stderr.trim()
315            )),
316        ),
317        Err(error) => (
318            list_conclusion,
319            Some(run.url.clone()),
320            Vec::new(),
321            Some(format!("could not inspect the failed run's jobs: {error}")),
322        ),
323    };
324    let jobs = if failed_jobs.is_empty() {
325        "no failed job detail was available".to_string()
326    } else {
327        failed_jobs
328            .iter()
329            .map(|job| format!("`{}` ({})", job.name, job.conclusion))
330            .collect::<Vec<_>>()
331            .join(", ")
332    };
333    let mut detail = format!(
334        "delegated workflow run {} ended `{conclusion}`; failed/cancelled job(s): {jobs}",
335        run.database_id
336    );
337    if let Some(extra) = extra {
338        let _ = write!(detail, "; {extra}");
339    }
340    DelegatedRun {
341        provider: "github-actions".to_string(),
342        workflow: Some(workflow.to_string()),
343        run_id: Some(run.database_id),
344        url,
345        status: DelegatedRunStatus::Failed,
346        conclusion: Some(conclusion),
347        failed_jobs,
348        detail: Some(detail),
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use std::cell::RefCell;
355    use std::io;
356    use std::path::Path;
357
358    use super::*;
359    use crate::ports::{Clock, CommandOutput, CommandRunner, RegistryQuery};
360    use crate::release::adapters::EMPTY_ARTIFACTS;
361
362    struct ClockFake;
363    impl Clock for ClockFake {
364        fn now_unix(&self) -> u64 {
365            0
366        }
367    }
368
369    struct RegistryFake;
370    impl RegistryQuery for RegistryFake {
371        fn published_versions(&self, _ecosystem: &str, _package: &str) -> io::Result<Vec<String>> {
372            Ok(Vec::new())
373        }
374    }
375
376    struct RunnerFake {
377        run_list: String,
378        run_view: Option<String>,
379        calls: RefCell<Vec<String>>,
380    }
381    impl CommandRunner for RunnerFake {
382        fn run(&self, program: &str, args: &[&str], _cwd: &Path) -> io::Result<CommandOutput> {
383            self.calls
384                .borrow_mut()
385                .push(format!("{program} {}", args.join(" ")));
386            let stdout = if program == "git" && args.starts_with(&["rev-list"]) {
387                "abc123\n".to_string()
388            } else if program == "git" && args.starts_with(&["grep"]) {
389                ".github/workflows/publish-crates.yml\n".to_string()
390            } else if program == "gh" && args.starts_with(&["run", "list"]) {
391                self.run_list.clone()
392            } else if program == "gh" && args.starts_with(&["run", "view"]) {
393                self.run_view.clone().unwrap_or_default()
394            } else {
395                String::new()
396            };
397            Ok(CommandOutput {
398                status: Some(0),
399                stdout,
400                stderr: String::new(),
401            })
402        }
403    }
404
405    fn ctx<'a>(
406        runner: &'a RunnerFake,
407        clock: &'a ClockFake,
408        registry: &'a RegistryFake,
409    ) -> EffectCtx<'a> {
410        EffectCtx {
411            runner,
412            clock,
413            registry,
414            repo_root: Path::new("/repo"),
415            artifacts: &EMPTY_ARTIFACTS,
416        }
417    }
418
419    #[test]
420    fn in_progress_run_is_pending_not_missing() {
421        let runner = RunnerFake {
422            run_list: r#"[{"databaseId":77,"status":"in_progress","conclusion":"","headBranch":"v1.0.0","headSha":"abc123","url":"https://example/run/77"}]"#.to_string(),
423            run_view: None,
424            calls: RefCell::new(Vec::new()),
425        };
426        let (clock, registry) = (ClockFake, RegistryFake);
427        let run = observe_github_run(
428            &ctx(&runner, &clock, &registry),
429            Adapter::CargoDist,
430            "1.0.0",
431        )
432        .unwrap();
433        assert_eq!(run.status, DelegatedRunStatus::Pending);
434        assert_eq!(run.run_id, Some(77));
435        assert!(!runner
436            .calls
437            .borrow()
438            .iter()
439            .any(|call| call.starts_with("gh release")));
440    }
441
442    #[test]
443    fn cancelled_run_reports_the_terminal_job_cause() {
444        let runner = RunnerFake {
445            run_list: r#"[{"databaseId":88,"status":"completed","conclusion":"cancelled","headBranch":"v1.0.0","headSha":"abc123","url":"https://example/run/88"}]"#.to_string(),
446            run_view: Some(r#"{"status":"completed","conclusion":"cancelled","url":"https://example/run/88","jobs":[{"name":"build (aarch64-unknown-linux-musl)","status":"completed","conclusion":"cancelled"},{"name":"host","status":"completed","conclusion":"skipped"}]}"#.to_string()),
447            calls: RefCell::new(Vec::new()),
448        };
449        let (clock, registry) = (ClockFake, RegistryFake);
450        let run = observe_github_run(
451            &ctx(&runner, &clock, &registry),
452            Adapter::CargoDist,
453            "1.0.0",
454        )
455        .unwrap();
456        assert_eq!(run.status, DelegatedRunStatus::Failed);
457        assert_eq!(run.conclusion.as_deref(), Some("cancelled"));
458        assert_eq!(
459            run.failed_jobs[0].name,
460            "build (aarch64-unknown-linux-musl)"
461        );
462        assert!(run.detail.unwrap().contains("cancelled"));
463    }
464
465    #[test]
466    fn successful_run_is_distinct_and_allows_destination_observation_to_follow() {
467        let runner = RunnerFake {
468            run_list: r#"[{"databaseId":99,"status":"completed","conclusion":"success","headBranch":"v1.0.0","headSha":"abc123","url":"https://example/run/99"}]"#.to_string(),
469            run_view: None,
470            calls: RefCell::new(Vec::new()),
471        };
472        let (clock, registry) = (ClockFake, RegistryFake);
473        let run = observe_github_run(
474            &ctx(&runner, &clock, &registry),
475            Adapter::CargoDist,
476            "1.0.0",
477        )
478        .unwrap();
479        assert_eq!(run.status, DelegatedRunStatus::Success);
480        assert_eq!(run.run_id, Some(99));
481    }
482
483    #[test]
484    fn non_github_delegated_adapters_have_no_github_run_state() {
485        struct NoCommands;
486        impl CommandRunner for NoCommands {
487            fn run(&self, program: &str, args: &[&str], _cwd: &Path) -> io::Result<CommandOutput> {
488                panic!("non-GitHub adapter invoked {program} {args:?}")
489            }
490        }
491        let (runner, clock, registry) = (NoCommands, ClockFake, RegistryFake);
492        let context = EffectCtx {
493            runner: &runner,
494            clock: &clock,
495            registry: &registry,
496            repo_root: Path::new("/repo"),
497            artifacts: &EMPTY_ARTIFACTS,
498        };
499        assert!(observe_github_run(&context, Adapter::ReleasePlease, "1.0.0").is_none());
500        assert!(observe_github_run(&context, Adapter::GhActionPypiPublish, "1.0.0").is_none());
501    }
502
503    #[test]
504    fn command_failure_is_unknown_not_missing_or_pending() {
505        struct Broken;
506        impl CommandRunner for Broken {
507            fn run(&self, program: &str, args: &[&str], _cwd: &Path) -> io::Result<CommandOutput> {
508                if program == "git" && args.starts_with(&["rev-list"]) {
509                    return Ok(CommandOutput {
510                        status: Some(0),
511                        stdout: "abc123\n".into(),
512                        stderr: String::new(),
513                    });
514                }
515                Err(io::Error::new(io::ErrorKind::TimedOut, "offline"))
516            }
517        }
518        let (broken, clock, registry) = (Broken, ClockFake, RegistryFake);
519        let context = EffectCtx {
520            runner: &broken,
521            clock: &clock,
522            registry: &registry,
523            repo_root: Path::new("/repo"),
524            artifacts: &EMPTY_ARTIFACTS,
525        };
526        let run = observe_github_run(&context, Adapter::CargoDist, "1.0.0").unwrap();
527        assert_eq!(run.status, DelegatedRunStatus::Unknown);
528    }
529}