1use std::path::{Path, PathBuf};
14
15use camino::Utf8Path;
16use serde::Serialize;
17use serde_json::Value;
18
19use crate::detect::Forge;
20use crate::landing::manifest::version_is_newer;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum Answer {
28 Yes,
30 No,
32 Unknown,
34 NotApplicable,
37}
38
39impl Answer {
40 #[must_use]
42 pub const fn as_str(self) -> &'static str {
43 match self {
44 Self::Yes => "yes",
45 Self::No => "no",
46 Self::Unknown => "unknown",
47 Self::NotApplicable => "not-applicable",
48 }
49 }
50
51 #[must_use]
53 pub const fn of(value: bool) -> Self {
54 if value { Self::Yes } else { Self::No }
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60#[serde(rename_all = "kebab-case")]
61pub enum Checks {
62 Pending,
64 Passing,
66 Failing,
68 Unknown,
70}
71
72impl Checks {
73 #[must_use]
75 pub const fn as_str(self) -> &'static str {
76 match self {
77 Self::Pending => "pending",
78 Self::Passing => "passing",
79 Self::Failing => "failing",
80 Self::Unknown => "unknown",
81 }
82 }
83}
84
85#[derive(Debug, Clone, Serialize)]
87pub struct Request {
88 pub state: &'static str,
90 pub number: Option<u64>,
92 pub proposed_version: Option<String>,
94 pub armed: Answer,
96 pub checks: Checks,
98 pub behind_trunk: Answer,
100}
101
102impl Request {
103 #[must_use]
105 pub const fn unknown() -> Self {
106 Self {
107 state: "unknown",
108 number: None,
109 proposed_version: None,
110 armed: Answer::Unknown,
111 checks: Checks::Unknown,
112 behind_trunk: Answer::Unknown,
113 }
114 }
115
116 #[must_use]
118 pub const fn none() -> Self {
119 Self {
120 state: "none",
121 number: None,
122 proposed_version: None,
123 armed: Answer::NotApplicable,
124 checks: Checks::Unknown,
125 behind_trunk: Answer::NotApplicable,
126 }
127 }
128}
129
130#[derive(Debug, Clone, Serialize)]
132pub struct LastRelease {
133 pub tag: Option<String>,
135 pub published: Answer,
138 pub artifacts_verified: Answer,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
144pub struct Symptom {
145 pub code: &'static str,
147 pub evidence: String,
149 pub recovery: &'static str,
151}
152
153pub const MERGED_NOTHING_PUBLISHED: &str = "request-merged-nothing-published";
155pub const PUBLISHED_NO_ARTIFACTS: &str = "published-no-artifacts";
157pub const ARTIFACT_WITHOUT_ATTESTATION: &str = "artifact-without-attestation";
159
160const RECOVERY_NOTHING_PUBLISHED: &str = "The release request merged and nothing published";
162const RECOVERY_ARTIFACT_BUILD: &str = "The artifact build failed";
165
166#[derive(Debug, Clone)]
168pub struct Pair {
169 pub driver: String,
171 pub forge: Forge,
173 pub repo: String,
175 pub trunk: String,
177 pub artifacts: bool,
179 pub package: Option<String>,
181}
182
183impl Pair {
184 #[must_use]
186 pub fn request_prefix(&self) -> Option<&'static str> {
187 match self.driver.as_str() {
188 "rust" => Some("release-plz-"),
189 "python" => Some("release-please--"),
190 "bash" => Some("chore/release-v"),
191 _ => None,
192 }
193 }
194
195 #[must_use]
197 pub fn publish_workflow(&self) -> Option<&'static str> {
198 match (self.forge, self.driver.as_str()) {
199 (Forge::Github, "rust") => Some("release-plz.yml"),
200 (Forge::Github, "python") => Some("release-please.yml"),
201 (Forge::Github, "bash") => Some("release.yml"),
202 _ => None,
203 }
204 }
205
206 #[must_use]
209 pub fn artifact_workflow(&self) -> Option<&'static str> {
210 if !self.artifacts {
211 return None;
212 }
213 match (self.forge, self.driver.as_str()) {
214 (Forge::Github, "rust" | "bash") => Some("release.yml"),
215 _ => None,
216 }
217 }
218
219 #[must_use]
221 pub fn registry(&self) -> Option<Registry> {
222 match self.driver.as_str() {
223 "rust" => Some(Registry::CratesIo),
224 "python" => Some(Registry::Pypi),
225 _ => None,
226 }
227 }
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum Registry {
233 CratesIo,
235 Pypi,
237}
238
239impl Registry {
240 #[must_use]
243 pub fn url(self, package: &str, version: Option<&str>) -> String {
244 match (self, version) {
245 (Self::CratesIo, Some(version)) => {
246 format!("https://crates.io/api/v1/crates/{package}/{version}")
247 }
248 (Self::CratesIo, None) => format!("https://crates.io/api/v1/crates/{package}"),
249 (Self::Pypi, Some(version)) => {
250 format!("https://pypi.org/pypi/{package}/{version}/json")
251 }
252 (Self::Pypi, None) => format!("https://pypi.org/pypi/{package}/json"),
253 }
254 }
255}
256
257#[must_use]
261pub fn proposed_version(title: &str) -> Option<String> {
262 title
263 .split_whitespace()
264 .rev()
265 .map(|token| token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '.'))
266 .map(|token| token.strip_prefix('v').unwrap_or(token))
267 .find(|token| is_version(token))
268 .map(str::to_owned)
269}
270
271#[must_use]
274pub fn is_version(text: &str) -> bool {
275 let core = text.split_once('-').map_or(text, |(core, _)| core);
276 let parts: Vec<&str> = core.split('.').collect();
277 parts.len() == 3
278 && parts
279 .iter()
280 .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
281}
282
283#[must_use]
286pub fn newest_release<'a>(tags: impl IntoIterator<Item = &'a str>) -> Option<String> {
287 let mut newest: Option<&str> = None;
288 for tag in tags {
289 let Some(version) = tag.strip_prefix('v') else {
290 continue;
291 };
292 if !is_version(version) || version.contains('-') {
293 continue;
294 }
295 if newest.is_none_or(|current| version_is_newer(version, ¤t[1..])) {
296 newest = Some(tag);
297 }
298 }
299 newest.map(str::to_owned)
300}
301
302#[must_use]
304pub fn github_checks(body: &Value) -> Checks {
305 let Some(runs) = body["check_runs"].as_array() else {
306 return Checks::Unknown;
307 };
308 if runs.is_empty() {
309 return Checks::Pending;
310 }
311 let failed = runs.iter().any(|run| {
312 matches!(
313 run["conclusion"].as_str(),
314 Some(
315 "failure"
316 | "cancelled"
317 | "timed_out"
318 | "action_required"
319 | "startup_failure"
320 | "stale"
321 )
322 )
323 });
324 if failed {
325 return Checks::Failing;
326 }
327 if runs
328 .iter()
329 .any(|run| run["status"].as_str() != Some("completed"))
330 {
331 return Checks::Pending;
332 }
333 Checks::Passing
334}
335
336#[must_use]
338pub fn gitlab_checks(status: Option<&str>) -> Checks {
339 match status {
340 Some("success") => Checks::Passing,
341 Some("failed" | "canceled" | "canceling") => Checks::Failing,
342 Some(_) | None => Checks::Pending,
343 }
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
348pub struct Run {
349 pub id: u64,
351 pub status: String,
354 pub conclusion: Option<String>,
356 pub head_branch: Option<String>,
358}
359
360impl Run {
361 #[must_use]
363 pub const fn concluded(&self) -> bool {
364 self.conclusion.is_some()
365 }
366
367 #[must_use]
369 pub fn succeeded(&self) -> bool {
370 self.conclusion.as_deref() == Some("success")
371 }
372}
373
374#[must_use]
378pub fn github_run_on(body: &Value, workflow: &str, sha: &str) -> Option<Run> {
379 let suffix = format!("/{workflow}");
380 body["workflow_runs"]
381 .as_array()?
382 .iter()
383 .filter(|run| run["head_sha"].as_str() == Some(sha))
384 .filter(|run| run["event"].as_str() == Some("push"))
385 .filter(|run| {
386 run["path"]
387 .as_str()
388 .is_some_and(|path| path.ends_with(&suffix) || path == workflow)
389 })
390 .map(|run| Run {
391 id: run["id"].as_u64().unwrap_or_default(),
392 status: run["status"].as_str().unwrap_or("unknown").to_owned(),
393 conclusion: (run["status"].as_str() == Some("completed"))
394 .then(|| run["conclusion"].as_str().map(str::to_owned))
395 .flatten(),
396 head_branch: run["head_branch"].as_str().map(str::to_owned),
397 })
398 .min_by_key(|run| run.id)
399}
400
401#[must_use]
403pub fn gitlab_pipeline_on(body: &Value, reference: impl Fn(&str) -> bool) -> Option<Run> {
404 body.as_array()?
405 .iter()
406 .filter(|pipeline| pipeline["ref"].as_str().is_some_and(&reference))
407 .map(|pipeline| {
408 let status = pipeline["status"].as_str().unwrap_or("unknown").to_owned();
409 let conclusion = matches!(
410 status.as_str(),
411 "success" | "failed" | "canceled" | "skipped"
412 )
413 .then(|| status.clone());
414 Run {
415 id: pipeline["id"].as_u64().unwrap_or_default(),
416 status,
417 conclusion,
418 head_branch: pipeline["ref"].as_str().map(str::to_owned),
419 }
420 })
421 .min_by_key(|run| run.id)
422}
423
424#[derive(Debug, Clone, Default, PartialEq, Eq)]
426pub struct Page {
427 pub exists: bool,
429 pub assets: Vec<String>,
431 pub unattested: Vec<String>,
433 pub attestation_unknown: bool,
435}
436
437impl Page {
438 #[must_use]
440 pub const fn verified(&self) -> Answer {
441 if !self.exists || self.assets.is_empty() || !self.unattested.is_empty() {
442 return Answer::No;
443 }
444 if self.attestation_unknown {
445 return Answer::Unknown;
446 }
447 Answer::Yes
448 }
449}
450
451#[derive(Debug, Clone)]
453pub struct Merged {
454 pub number: u64,
456 pub version: String,
458 pub tagged: Answer,
460 pub published: Answer,
462}
463
464#[must_use]
474pub fn symptoms(
475 merged: Option<&Merged>,
476 last: &LastRelease,
477 page: Option<&Page>,
478 artifacts: bool,
479) -> Vec<Symptom> {
480 let mut found = Vec::new();
481 if let Some(merged) = merged {
482 let missing = match (merged.tagged, merged.published) {
483 (Answer::No, _) => Some(format!("no tag v{} exists", merged.version)),
484 (_, Answer::No) => Some(format!("{} is not published", merged.version)),
485 _ => None,
486 };
487 if let Some(missing) = missing {
488 found.push(Symptom {
489 code: MERGED_NOTHING_PUBLISHED,
490 evidence: format!(
491 "release request #{} proposing {} merged, and {missing}",
492 merged.number, merged.version
493 ),
494 recovery: RECOVERY_NOTHING_PUBLISHED,
495 });
496 }
497 }
498 if let (true, Some(tag), Answer::Yes, Some(page)) =
499 (artifacts, last.tag.as_deref(), last.published, page)
500 {
501 if page.assets.is_empty() {
502 found.push(Symptom {
503 code: PUBLISHED_NO_ARTIFACTS,
504 evidence: format!("{tag} is published and its release page carries no artifact"),
505 recovery: RECOVERY_ARTIFACT_BUILD,
506 });
507 } else if !page.unattested.is_empty() {
508 found.push(Symptom {
509 code: ARTIFACT_WITHOUT_ATTESTATION,
510 evidence: format!(
511 "{tag} carries {} with no attestation",
512 page.unattested.join(", ")
513 ),
514 recovery: RECOVERY_ARTIFACT_BUILD,
515 });
516 }
517 }
518 found
519}
520
521#[derive(Debug)]
523pub enum Api {
524 Ok(Value),
526 Missing,
528 Failed(String),
530}
531
532#[derive(Debug, Clone)]
534pub struct ForgeCli {
535 cli: PathBuf,
536 cwd: PathBuf,
537}
538
539impl ForgeCli {
540 #[must_use]
542 pub const fn new(cli: PathBuf, cwd: PathBuf) -> Self {
543 Self { cli, cwd }
544 }
545
546 pub fn resolve(forge: Forge, target: &Utf8Path) -> Result<Self, crate::error::RkError> {
552 Ok(Self {
553 cli: crate::setup::context::resolve_cli(forge)?,
554 cwd: target.as_std_path().to_path_buf(),
555 })
556 }
557
558 #[must_use]
560 pub fn api(&self, path: &str) -> Api {
561 let answered = std::process::Command::new(&self.cli)
562 .args(["api", path])
563 .current_dir(&self.cwd)
564 .env("GH_PAGER", "")
565 .env("GLAB_PAGER", "")
566 .env("NO_COLOR", "1")
567 .output();
568 let output = match answered {
569 Ok(output) => output,
570 Err(source) => return Api::Failed(format!("the forge CLI did not run: {source}")),
571 };
572 if output.status.success() {
573 return serde_json::from_slice::<Value>(&output.stdout).map_or_else(
574 |_| Api::Failed("the forge answer did not parse as JSON".to_owned()),
575 Api::Ok,
576 );
577 }
578 let stderr = String::from_utf8_lossy(&output.stderr);
581 if stderr.contains("HTTP 404") || stderr.contains("404 Not Found") {
582 return Api::Missing;
583 }
584 Api::Failed(
585 stderr
586 .lines()
587 .rev()
588 .find(|line| !line.trim().is_empty())
589 .unwrap_or("no output")
590 .to_owned(),
591 )
592 }
593}
594
595#[must_use]
599pub fn registry_serves(registry: Registry, package: &str, version: Option<&str>) -> Answer {
600 let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
601 let answered = std::process::Command::new(curl)
602 .args([
603 "-sS",
604 "-o",
605 "/dev/null",
606 "-w",
607 "%{http_code}",
608 "--max-time",
609 "20",
610 ])
611 .args(["-A", concat!("release-kit/", env!("CARGO_PKG_VERSION"))])
612 .arg(registry.url(package, version))
613 .output();
614 match answered {
615 Ok(output) => match String::from_utf8_lossy(&output.stdout).trim() {
616 "200" => Answer::Yes,
617 "404" => Answer::No,
618 _ => Answer::Unknown,
619 },
620 Err(_) => Answer::Unknown,
621 }
622}
623
624#[must_use]
628pub fn package_name(target: &Path, driver: &str) -> Option<String> {
629 let (file, table) = match driver {
630 "rust" => ("Cargo.toml", "package"),
631 "python" => ("pyproject.toml", "project"),
632 _ => return None,
633 };
634 let text = std::fs::read_to_string(target.join(file)).ok()?;
635 let value: toml::Table = toml::from_str(&text).ok()?;
636 value.get(table)?.get("name")?.as_str().map(str::to_owned)
637}
638
639#[must_use]
641pub fn gitlab_project(repo: &str) -> String {
642 repo.replace('/', "%2F")
643}
644
645#[cfg(test)]
646mod tests {
647 use serde_json::json;
648
649 use super::{
650 Answer, Checks, LastRelease, Merged, Page, github_checks, github_run_on, gitlab_checks,
651 newest_release, proposed_version, symptoms,
652 };
653
654 #[test]
655 fn a_title_proposes_its_last_version_token() {
656 assert_eq!(
657 proposed_version("chore: release v0.8.7").as_deref(),
658 Some("0.8.7")
659 );
660 assert_eq!(
661 proposed_version("chore(main): release 1.2.3").as_deref(),
662 Some("1.2.3")
663 );
664 assert_eq!(
665 proposed_version("chore(release): v2.0.0-rc.1").as_deref(),
666 Some("2.0.0-rc.1")
667 );
668 assert_eq!(proposed_version("chore: release"), None);
669 }
670
671 #[test]
672 fn the_newest_release_skips_candidates_and_other_tags() {
673 assert_eq!(
674 newest_release(["v0.9.0", "v0.10.0", "v0.11.0-rc.1", "latest", "v1.2"]).as_deref(),
675 Some("v0.10.0")
676 );
677 assert_eq!(newest_release(["nightly"]), None);
678 }
679
680 #[test]
681 fn check_runs_judge_failing_before_pending() {
682 assert_eq!(github_checks(&json!({"check_runs": []})), Checks::Pending);
683 assert_eq!(
684 github_checks(&json!({"check_runs": [
685 {"status": "completed", "conclusion": "success"},
686 {"status": "in_progress", "conclusion": null}
687 ]})),
688 Checks::Pending
689 );
690 assert_eq!(
691 github_checks(&json!({"check_runs": [
692 {"status": "completed", "conclusion": "failure"},
693 {"status": "in_progress", "conclusion": null}
694 ]})),
695 Checks::Failing
696 );
697 assert_eq!(
698 github_checks(&json!({"check_runs": [
699 {"status": "completed", "conclusion": "success"},
700 {"status": "completed", "conclusion": "skipped"}
701 ]})),
702 Checks::Passing
703 );
704 assert_eq!(github_checks(&json!({})), Checks::Unknown);
705 assert_eq!(gitlab_checks(Some("running")), Checks::Pending);
706 assert_eq!(gitlab_checks(Some("failed")), Checks::Failing);
707 assert_eq!(gitlab_checks(Some("success")), Checks::Passing);
708 }
709
710 #[test]
713 fn a_run_is_selected_by_its_commit_never_by_recency() {
714 let body = json!({"workflow_runs": [
715 {"id": 30, "head_sha": "bbb", "event": "pull_request", "status": "in_progress",
716 "conclusion": null, "path": ".github/workflows/release-plz.yml", "head_branch": "feat/x"},
717 {"id": 20, "head_sha": "aaa", "event": "push", "status": "completed",
718 "conclusion": "success", "path": ".github/workflows/release-plz.yml", "head_branch": "master"},
719 {"id": 21, "head_sha": "aaa", "event": "push", "status": "queued",
720 "conclusion": null, "path": ".github/workflows/release.yml", "head_branch": "v1.0.0"}
721 ]});
722 let publish = github_run_on(&body, "release-plz.yml", "aaa").expect("the publish run");
723 assert_eq!(publish.id, 20);
724 assert!(publish.succeeded());
725 let artifact = github_run_on(&body, "release.yml", "aaa").expect("the artifact run");
726 assert_eq!(artifact.id, 21);
727 assert!(!artifact.concluded());
728 assert_eq!(github_run_on(&body, "release-plz.yml", "bbb"), None);
729 }
730
731 fn last(tag: &str, published: Answer) -> LastRelease {
732 LastRelease {
733 tag: Some(tag.to_owned()),
734 published,
735 artifacts_verified: Answer::Unknown,
736 }
737 }
738
739 #[test]
740 fn each_symptom_has_its_rule() {
741 let merged = Merged {
742 number: 12,
743 version: "1.2.3".into(),
744 tagged: Answer::No,
745 published: Answer::Unknown,
746 };
747 let found = symptoms(Some(&merged), &last("v1.2.2", Answer::Yes), None, false);
748 assert_eq!(found.len(), 1);
749 assert_eq!(found[0].code, super::MERGED_NOTHING_PUBLISHED);
750 assert_eq!(
751 found[0].recovery,
752 "The release request merged and nothing published"
753 );
754 let unpublished = Merged {
755 tagged: Answer::Yes,
756 published: Answer::No,
757 ..merged.clone()
758 };
759 assert_eq!(
760 symptoms(Some(&unpublished), &last("v1.2.3", Answer::No), None, false)[0].code,
761 super::MERGED_NOTHING_PUBLISHED
762 );
763 let shipped = Merged {
764 tagged: Answer::Yes,
765 published: Answer::Yes,
766 ..merged
767 };
768 let empty = Page {
769 exists: true,
770 ..Page::default()
771 };
772 let found = symptoms(
773 Some(&shipped),
774 &last("v1.2.3", Answer::Yes),
775 Some(&empty),
776 true,
777 );
778 assert_eq!(found.len(), 1);
779 assert_eq!(found[0].code, super::PUBLISHED_NO_ARTIFACTS);
780 assert_eq!(found[0].recovery, "The artifact build failed");
781 let unattested = Page {
782 exists: true,
783 assets: vec!["widget.tar.gz".into()],
784 unattested: vec!["widget.tar.gz".into()],
785 attestation_unknown: false,
786 };
787 let found = symptoms(None, &last("v1.2.3", Answer::Yes), Some(&unattested), true);
788 assert_eq!(found[0].code, super::ARTIFACT_WITHOUT_ATTESTATION);
789 assert!(found[0].evidence.contains("widget.tar.gz"));
790 assert!(
791 symptoms(None, &last("v1.2.3", Answer::Yes), Some(&empty), false).is_empty(),
792 "a pair that builds no artifact has no artifact symptom"
793 );
794 }
795
796 #[test]
797 fn the_answer_words_are_the_wire_form() {
798 for (answer, word) in [
799 (Answer::Yes, "yes"),
800 (Answer::No, "no"),
801 (Answer::Unknown, "unknown"),
802 (Answer::NotApplicable, "not-applicable"),
803 ] {
804 assert_eq!(answer.as_str(), word);
805 assert_eq!(
806 serde_json::to_string(&answer).expect("serializes"),
807 format!("\"{word}\"")
808 );
809 }
810 assert_eq!(Answer::of(true), Answer::Yes);
811 }
812}