1use 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#[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)] fn 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)] fn 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, ®istry),
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, ®istry),
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, ®istry),
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: ®istry,
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: ®istry,
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}