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
160pub const TAG_WITHOUT_RELEASE: &str = "tag-without-release";
162
163const RECOVERY_TAG_WITHOUT_RELEASE: &str = "The tag exists and the release does not";
166
167const RECOVERY_NOTHING_PUBLISHED: &str = "The release request merged and nothing published";
169const RECOVERY_ARTIFACT_BUILD: &str = "The artifact build failed";
172
173#[derive(Debug, Clone)]
175pub struct Pair {
176 pub driver: String,
178 pub forge: Forge,
180 pub repo: String,
182 pub trunk: String,
184 pub artifacts: bool,
186 pub package: Option<String>,
188 pub registry_answer: Option<crate::profile::RegistryAnswer>,
191}
192
193impl Pair {
194 #[must_use]
196 pub fn request_prefix(&self) -> Option<&'static str> {
197 match self.driver.as_str() {
198 "rust" => Some("release-plz-"),
199 "python" => Some("release-please--"),
200 "bash" => Some("chore/release-v"),
201 _ => None,
202 }
203 }
204
205 #[must_use]
207 pub fn publish_workflow(&self) -> Option<&'static str> {
208 match (self.forge, self.driver.as_str()) {
209 (Forge::Github, "rust") => Some("release-plz.yml"),
210 (Forge::Github, "python") => Some("release-please.yml"),
211 (Forge::Github, "bash") => Some("release.yml"),
212 _ => None,
213 }
214 }
215
216 #[must_use]
219 pub fn artifact_workflow(&self) -> Option<&'static str> {
220 if !self.artifacts {
221 return None;
222 }
223 match (self.forge, self.driver.as_str()) {
224 (Forge::Github, "rust" | "bash") => Some("release.yml"),
225 _ => None,
226 }
227 }
228
229 #[must_use]
231 pub fn registry(&self) -> Option<Registry> {
232 registry_for(&self.driver, self.registry_answer)
233 }
234}
235
236#[must_use]
244pub fn registry_for(
245 driver: &str,
246 answer: Option<crate::profile::RegistryAnswer>,
247) -> Option<Registry> {
248 match (driver, answer) {
249 ("rust", Some(crate::profile::RegistryAnswer::None)) => None,
250 ("rust", _) => Some(Registry::CratesIo),
251 ("python", _) => Some(Registry::Pypi),
252 _ => None,
253 }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum Registry {
259 CratesIo,
261 Pypi,
263}
264
265impl Registry {
266 #[must_use]
269 pub fn url(self, package: &str, version: Option<&str>) -> String {
270 match (self, version) {
271 (Self::CratesIo, Some(version)) => {
272 format!("https://crates.io/api/v1/crates/{package}/{version}")
273 }
274 (Self::CratesIo, None) => format!("https://crates.io/api/v1/crates/{package}"),
275 (Self::Pypi, Some(version)) => {
276 format!("https://pypi.org/pypi/{package}/{version}/json")
277 }
278 (Self::Pypi, None) => format!("https://pypi.org/pypi/{package}/json"),
279 }
280 }
281}
282
283#[must_use]
287pub fn proposed_version(title: &str) -> Option<String> {
288 title
289 .split_whitespace()
290 .rev()
291 .map(|token| token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '.'))
292 .map(|token| token.strip_prefix('v').unwrap_or(token))
293 .find(|token| is_version(token))
294 .map(str::to_owned)
295}
296
297#[must_use]
300pub fn is_version(text: &str) -> bool {
301 let core = text.split_once('-').map_or(text, |(core, _)| core);
302 let parts: Vec<&str> = core.split('.').collect();
303 parts.len() == 3
304 && parts
305 .iter()
306 .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
307}
308
309#[must_use]
312pub fn newest_release<'a>(tags: impl IntoIterator<Item = &'a str>) -> Option<String> {
313 let mut newest: Option<&str> = None;
314 for tag in tags {
315 let Some(version) = tag.strip_prefix('v') else {
316 continue;
317 };
318 if !is_version(version) || version.contains('-') {
319 continue;
320 }
321 if newest.is_none_or(|current| version_is_newer(version, ¤t[1..])) {
322 newest = Some(tag);
323 }
324 }
325 newest.map(str::to_owned)
326}
327
328#[must_use]
330pub fn github_checks(body: &Value) -> Checks {
331 let Some(runs) = body["check_runs"].as_array() else {
332 return Checks::Unknown;
333 };
334 if runs.is_empty() {
335 return Checks::Pending;
336 }
337 let failed = runs.iter().any(|run| {
338 matches!(
339 run["conclusion"].as_str(),
340 Some(
341 "failure"
342 | "cancelled"
343 | "timed_out"
344 | "action_required"
345 | "startup_failure"
346 | "stale"
347 )
348 )
349 });
350 if failed {
351 return Checks::Failing;
352 }
353 if runs
354 .iter()
355 .any(|run| run["status"].as_str() != Some("completed"))
356 {
357 return Checks::Pending;
358 }
359 Checks::Passing
360}
361
362#[must_use]
364pub fn gitlab_checks(status: Option<&str>) -> Checks {
365 match status {
366 Some("success") => Checks::Passing,
367 Some("failed" | "canceled" | "canceling") => Checks::Failing,
368 Some(_) | None => Checks::Pending,
369 }
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
374pub struct Run {
375 pub id: u64,
377 pub status: String,
380 pub conclusion: Option<String>,
382 pub head_branch: Option<String>,
384}
385
386impl Run {
387 #[must_use]
389 pub const fn concluded(&self) -> bool {
390 self.conclusion.is_some()
391 }
392
393 #[must_use]
395 pub fn succeeded(&self) -> bool {
396 self.conclusion.as_deref() == Some("success")
397 }
398}
399
400#[must_use]
404pub fn github_run_on(body: &Value, workflow: &str, sha: &str) -> Option<Run> {
405 let suffix = format!("/{workflow}");
406 body["workflow_runs"]
407 .as_array()?
408 .iter()
409 .filter(|run| run["head_sha"].as_str() == Some(sha))
410 .filter(|run| run["event"].as_str() == Some("push"))
411 .filter(|run| {
412 run["path"]
413 .as_str()
414 .is_some_and(|path| path.ends_with(&suffix) || path == workflow)
415 })
416 .map(|run| Run {
417 id: run["id"].as_u64().unwrap_or_default(),
418 status: run["status"].as_str().unwrap_or("unknown").to_owned(),
419 conclusion: (run["status"].as_str() == Some("completed"))
420 .then(|| run["conclusion"].as_str().map(str::to_owned))
421 .flatten(),
422 head_branch: run["head_branch"].as_str().map(str::to_owned),
423 })
424 .min_by_key(|run| run.id)
425}
426
427#[must_use]
429pub fn gitlab_pipeline_on(body: &Value, reference: impl Fn(&str) -> bool) -> Option<Run> {
430 body.as_array()?
431 .iter()
432 .filter(|pipeline| pipeline["ref"].as_str().is_some_and(&reference))
433 .map(|pipeline| {
434 let status = pipeline["status"].as_str().unwrap_or("unknown").to_owned();
435 let conclusion = matches!(
436 status.as_str(),
437 "success" | "failed" | "canceled" | "skipped"
438 )
439 .then(|| status.clone());
440 Run {
441 id: pipeline["id"].as_u64().unwrap_or_default(),
442 status,
443 conclusion,
444 head_branch: pipeline["ref"].as_str().map(str::to_owned),
445 }
446 })
447 .min_by_key(|run| run.id)
448}
449
450#[derive(Debug, Clone, Default, PartialEq, Eq)]
452pub struct Page {
453 pub exists: bool,
455 pub assets: Vec<String>,
457 pub unattested: Vec<String>,
459 pub attestation_unknown: bool,
461}
462
463impl Page {
464 #[must_use]
466 pub const fn verified(&self) -> Answer {
467 if !self.exists || self.assets.is_empty() || !self.unattested.is_empty() {
468 return Answer::No;
469 }
470 if self.attestation_unknown {
471 return Answer::Unknown;
472 }
473 Answer::Yes
474 }
475}
476
477#[derive(Debug, Clone)]
479pub struct Merged {
480 pub number: u64,
482 pub version: String,
484 pub tagged: Answer,
486 pub published: Answer,
488}
489
490#[must_use]
500pub fn symptoms(
501 merged: Option<&Merged>,
502 last: &LastRelease,
503 page: Option<&Page>,
504 artifacts: bool,
505) -> Vec<Symptom> {
506 let mut found = Vec::new();
507 if let Some(merged) = merged {
508 let missing = match (merged.tagged, merged.published) {
509 (Answer::No, _) => Some(format!("no tag v{} exists", merged.version)),
510 (_, Answer::No) => Some(format!("{} is not published", merged.version)),
511 _ => None,
512 };
513 if let Some(missing) = missing {
514 found.push(Symptom {
515 code: MERGED_NOTHING_PUBLISHED,
516 evidence: format!(
517 "release request #{} proposing {} merged, and {missing}",
518 merged.number, merged.version
519 ),
520 recovery: RECOVERY_NOTHING_PUBLISHED,
521 });
522 }
523 }
524 if let (true, Some(tag), Answer::Yes, Some(page)) =
525 (artifacts, last.tag.as_deref(), last.published, page)
526 {
527 if page.assets.is_empty() {
528 found.push(Symptom {
529 code: PUBLISHED_NO_ARTIFACTS,
530 evidence: format!("{tag} is published and its release page carries no artifact"),
531 recovery: RECOVERY_ARTIFACT_BUILD,
532 });
533 } else if !page.unattested.is_empty() {
534 found.push(Symptom {
535 code: ARTIFACT_WITHOUT_ATTESTATION,
536 evidence: format!(
537 "{tag} carries {} with no attestation",
538 page.unattested.join(", ")
539 ),
540 recovery: RECOVERY_ARTIFACT_BUILD,
541 });
542 }
543 }
544 found
545}
546
547pub fn orphan_tags(
559 tags: &[&str],
560 lines: &[&str],
561 mut complete: impl FnMut(&str) -> Answer,
562) -> Vec<Symptom> {
563 let line_of = |version: &str| {
564 lines
565 .iter()
566 .find(|line| version.starts_with(&format!("{line}.")))
567 .copied()
568 };
569 let mut groups: std::collections::BTreeMap<Option<&str>, Vec<&str>> =
570 std::collections::BTreeMap::new();
571 for tag in tags {
572 let Some(version) = tag.strip_prefix('v') else {
573 continue;
574 };
575 if !is_version(version) || version.contains('-') {
576 continue;
577 }
578 groups.entry(line_of(version)).or_default().push(tag);
579 }
580 let mut found = Vec::new();
581 for (line, mut group) in groups {
582 group.sort_by(|left, right| {
583 if version_is_newer(&left[1..], &right[1..]) {
584 std::cmp::Ordering::Less
585 } else if version_is_newer(&right[1..], &left[1..]) {
586 std::cmp::Ordering::Greater
587 } else {
588 std::cmp::Ordering::Equal
589 }
590 });
591 let place = line.map_or_else(|| "the trunk".to_owned(), |line| format!("the line {line}"));
592 for tag in group {
593 if complete(tag) != Answer::No {
596 break;
597 }
598 found.push(Symptom {
599 code: TAG_WITHOUT_RELEASE,
600 evidence: format!(
601 "{tag} exists on {place} with no complete release page, and no complete release of that line is newer"
602 ),
603 recovery: RECOVERY_TAG_WITHOUT_RELEASE,
604 });
605 }
606 }
607 found
608}
609
610#[derive(Debug)]
612pub enum Api {
613 Ok(Value),
615 Missing,
617 Failed(String),
619}
620
621#[derive(Debug, Clone)]
623pub struct ForgeCli {
624 cli: PathBuf,
625 cwd: PathBuf,
626}
627
628impl ForgeCli {
629 #[must_use]
631 pub const fn new(cli: PathBuf, cwd: PathBuf) -> Self {
632 Self { cli, cwd }
633 }
634
635 pub fn resolve(forge: Forge, target: &Utf8Path) -> Result<Self, crate::error::RkError> {
641 Ok(Self {
642 cli: crate::setup::context::resolve_cli(forge)?,
643 cwd: target.as_std_path().to_path_buf(),
644 })
645 }
646
647 #[must_use]
649 pub fn api(&self, path: &str) -> Api {
650 let answered = std::process::Command::new(&self.cli)
651 .args(["api", path])
652 .current_dir(&self.cwd)
653 .env("GH_PAGER", "")
654 .env("GLAB_PAGER", "")
655 .env("NO_COLOR", "1")
656 .output();
657 let output = match answered {
658 Ok(output) => output,
659 Err(source) => return Api::Failed(format!("the forge CLI did not run: {source}")),
660 };
661 if output.status.success() {
662 return serde_json::from_slice::<Value>(&output.stdout).map_or_else(
663 |_| Api::Failed("the forge answer did not parse as JSON".to_owned()),
664 Api::Ok,
665 );
666 }
667 let stderr = String::from_utf8_lossy(&output.stderr);
670 if stderr.contains("HTTP 404") || stderr.contains("404 Not Found") {
671 return Api::Missing;
672 }
673 Api::Failed(
674 stderr
675 .lines()
676 .rev()
677 .find(|line| !line.trim().is_empty())
678 .unwrap_or("no output")
679 .to_owned(),
680 )
681 }
682}
683
684#[must_use]
688pub fn registry_serves(registry: Registry, package: &str, version: Option<&str>) -> Answer {
689 let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
690 let answered = std::process::Command::new(curl)
691 .args([
692 "-sS",
693 "-o",
694 "/dev/null",
695 "-w",
696 "%{http_code}",
697 "--max-time",
698 "20",
699 ])
700 .args(["-A", concat!("release-kit/", env!("CARGO_PKG_VERSION"))])
701 .arg(registry.url(package, version))
702 .output();
703 match answered {
704 Ok(output) => match String::from_utf8_lossy(&output.stdout).trim() {
705 "200" => Answer::Yes,
706 "404" => Answer::No,
707 _ => Answer::Unknown,
708 },
709 Err(_) => Answer::Unknown,
710 }
711}
712
713#[must_use]
717pub fn package_name(target: &Path, driver: &str) -> Option<String> {
718 let (file, table) = match driver {
719 "rust" => ("Cargo.toml", "package"),
720 "python" => ("pyproject.toml", "project"),
721 _ => return None,
722 };
723 let text = std::fs::read_to_string(target.join(file)).ok()?;
724 let value: toml::Table = toml::from_str(&text).ok()?;
725 value.get(table)?.get("name")?.as_str().map(str::to_owned)
726}
727
728#[must_use]
730pub fn gitlab_project(repo: &str) -> String {
731 repo.replace('/', "%2F")
732}
733
734#[cfg(test)]
735mod tests {
736 use serde_json::json;
737
738 use super::{
739 Answer, Checks, LastRelease, Merged, Page, github_checks, github_run_on, gitlab_checks,
740 newest_release, proposed_version, symptoms,
741 };
742
743 #[test]
744 fn a_title_proposes_its_last_version_token() {
745 assert_eq!(
746 proposed_version("chore: release v0.8.7").as_deref(),
747 Some("0.8.7")
748 );
749 assert_eq!(
750 proposed_version("chore(main): release 1.2.3").as_deref(),
751 Some("1.2.3")
752 );
753 assert_eq!(
754 proposed_version("chore(release): v2.0.0-rc.1").as_deref(),
755 Some("2.0.0-rc.1")
756 );
757 assert_eq!(proposed_version("chore: release"), None);
758 }
759
760 #[test]
761 fn the_newest_release_skips_candidates_and_other_tags() {
762 assert_eq!(
763 newest_release(["v0.9.0", "v0.10.0", "v0.11.0-rc.1", "latest", "v1.2"]).as_deref(),
764 Some("v0.10.0")
765 );
766 assert_eq!(newest_release(["nightly"]), None);
767 }
768
769 #[test]
770 fn check_runs_judge_failing_before_pending() {
771 assert_eq!(github_checks(&json!({"check_runs": []})), Checks::Pending);
772 assert_eq!(
773 github_checks(&json!({"check_runs": [
774 {"status": "completed", "conclusion": "success"},
775 {"status": "in_progress", "conclusion": null}
776 ]})),
777 Checks::Pending
778 );
779 assert_eq!(
780 github_checks(&json!({"check_runs": [
781 {"status": "completed", "conclusion": "failure"},
782 {"status": "in_progress", "conclusion": null}
783 ]})),
784 Checks::Failing
785 );
786 assert_eq!(
787 github_checks(&json!({"check_runs": [
788 {"status": "completed", "conclusion": "success"},
789 {"status": "completed", "conclusion": "skipped"}
790 ]})),
791 Checks::Passing
792 );
793 assert_eq!(github_checks(&json!({})), Checks::Unknown);
794 assert_eq!(gitlab_checks(Some("running")), Checks::Pending);
795 assert_eq!(gitlab_checks(Some("failed")), Checks::Failing);
796 assert_eq!(gitlab_checks(Some("success")), Checks::Passing);
797 }
798
799 #[test]
802 fn a_run_is_selected_by_its_commit_never_by_recency() {
803 let body = json!({"workflow_runs": [
804 {"id": 30, "head_sha": "bbb", "event": "pull_request", "status": "in_progress",
805 "conclusion": null, "path": ".github/workflows/release-plz.yml", "head_branch": "feat/x"},
806 {"id": 20, "head_sha": "aaa", "event": "push", "status": "completed",
807 "conclusion": "success", "path": ".github/workflows/release-plz.yml", "head_branch": "master"},
808 {"id": 21, "head_sha": "aaa", "event": "push", "status": "queued",
809 "conclusion": null, "path": ".github/workflows/release.yml", "head_branch": "v1.0.0"}
810 ]});
811 let publish = github_run_on(&body, "release-plz.yml", "aaa").expect("the publish run");
812 assert_eq!(publish.id, 20);
813 assert!(publish.succeeded());
814 let artifact = github_run_on(&body, "release.yml", "aaa").expect("the artifact run");
815 assert_eq!(artifact.id, 21);
816 assert!(!artifact.concluded());
817 assert_eq!(github_run_on(&body, "release-plz.yml", "bbb"), None);
818 }
819
820 fn last(tag: &str, published: Answer) -> LastRelease {
821 LastRelease {
822 tag: Some(tag.to_owned()),
823 published,
824 artifacts_verified: Answer::Unknown,
825 }
826 }
827
828 #[test]
829 fn each_symptom_has_its_rule() {
830 let merged = Merged {
831 number: 12,
832 version: "1.2.3".into(),
833 tagged: Answer::No,
834 published: Answer::Unknown,
835 };
836 let found = symptoms(Some(&merged), &last("v1.2.2", Answer::Yes), None, false);
837 assert_eq!(found.len(), 1);
838 assert_eq!(found[0].code, super::MERGED_NOTHING_PUBLISHED);
839 assert_eq!(
840 found[0].recovery,
841 "The release request merged and nothing published"
842 );
843 let unpublished = Merged {
844 tagged: Answer::Yes,
845 published: Answer::No,
846 ..merged.clone()
847 };
848 assert_eq!(
849 symptoms(Some(&unpublished), &last("v1.2.3", Answer::No), None, false)[0].code,
850 super::MERGED_NOTHING_PUBLISHED
851 );
852 let shipped = Merged {
853 tagged: Answer::Yes,
854 published: Answer::Yes,
855 ..merged
856 };
857 let empty = Page {
858 exists: true,
859 ..Page::default()
860 };
861 let found = symptoms(
862 Some(&shipped),
863 &last("v1.2.3", Answer::Yes),
864 Some(&empty),
865 true,
866 );
867 assert_eq!(found.len(), 1);
868 assert_eq!(found[0].code, super::PUBLISHED_NO_ARTIFACTS);
869 assert_eq!(found[0].recovery, "The artifact build failed");
870 let unattested = Page {
871 exists: true,
872 assets: vec!["widget.tar.gz".into()],
873 unattested: vec!["widget.tar.gz".into()],
874 attestation_unknown: false,
875 };
876 let found = symptoms(None, &last("v1.2.3", Answer::Yes), Some(&unattested), true);
877 assert_eq!(found[0].code, super::ARTIFACT_WITHOUT_ATTESTATION);
878 assert!(found[0].evidence.contains("widget.tar.gz"));
879 assert!(
880 symptoms(None, &last("v1.2.3", Answer::Yes), Some(&empty), false).is_empty(),
881 "a pair that builds no artifact has no artifact symptom"
882 );
883 }
884
885 #[test]
886 fn the_answer_words_are_the_wire_form() {
887 for (answer, word) in [
888 (Answer::Yes, "yes"),
889 (Answer::No, "no"),
890 (Answer::Unknown, "unknown"),
891 (Answer::NotApplicable, "not-applicable"),
892 ] {
893 assert_eq!(answer.as_str(), word);
894 assert_eq!(
895 serde_json::to_string(&answer).expect("serializes"),
896 format!("\"{word}\"")
897 );
898 }
899 assert_eq!(Answer::of(true), Answer::Yes);
900 }
901}