1use std::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 REQUEST_ARMED: &str = "request-armed";
163
164const RECOVERY_REQUEST_ARMED: &str = "A release request stands armed";
166
167#[must_use]
176pub fn armed_request(request: &Request, forge: Forge, repo: &str) -> Option<Symptom> {
177 if request.armed != Answer::Yes {
178 return None;
179 }
180 let number = request.number?;
181 Some(Symptom {
182 code: REQUEST_ARMED,
183 evidence: format!(
184 "release request {} stands armed to merge itself, and a release waits for the operator's order; {} disarms it",
185 request_name(forge, number),
186 disarm_command(forge, repo, number)
187 ),
188 recovery: RECOVERY_REQUEST_ARMED,
189 })
190}
191
192#[must_use]
194pub fn request_name(forge: Forge, number: u64) -> String {
195 match forge {
196 Forge::Github => format!("#{number}"),
197 Forge::Gitlab => format!("!{number}"),
198 }
199}
200
201#[must_use]
203pub fn disarm_command(forge: Forge, repo: &str, number: u64) -> String {
204 match forge {
205 Forge::Github => format!("gh pr merge {number} --disable-auto --repo {repo}"),
206 Forge::Gitlab => format!(
207 "glab api -X POST projects/{}/merge_requests/{number}/cancel_merge_when_pipeline_succeeds",
208 gitlab_project(repo)
209 ),
210 }
211}
212
213pub const TAG_WITHOUT_RELEASE: &str = "tag-without-release";
215
216const RECOVERY_TAG_WITHOUT_RELEASE: &str = "The tag exists and the release does not";
219
220pub const LOCAL_TAG_DIFFERS: &str = "local-tag-differs";
223pub const LOCAL_TAG_NOT_ON_FORGE: &str = "local-tag-not-on-forge";
225
226const RECOVERY_STALE_LOCAL_TAG: &str = "A clone holds a stale release tag";
229
230const RECOVERY_NOTHING_PUBLISHED: &str = "The release request merged and nothing published";
232const RECOVERY_ARTIFACT_BUILD: &str = "The artifact build failed";
235
236#[derive(Debug, Clone)]
238pub struct Pair {
239 pub driver: String,
241 pub forge: Forge,
243 pub repo: String,
245 pub trunk: String,
247 pub line_prefix: String,
249 pub artifacts: bool,
251 pub package: Option<String>,
253 pub registry_answer: Option<crate::profile::RegistryAnswer>,
256}
257
258impl Pair {
259 #[must_use]
261 pub fn request_prefix(&self) -> Option<&'static str> {
262 match self.driver.as_str() {
263 "rust" => Some("release-plz-"),
264 "python" | "bash" => Some(crate::release_engine::REQUEST_PREFIX),
266 _ => None,
267 }
268 }
269
270 #[must_use]
272 pub fn publish_workflow(&self) -> Option<&'static str> {
273 match (self.forge, self.driver.as_str()) {
274 (Forge::Github, "rust") => Some("release-plz.yml"),
275 (Forge::Github, "python" | "bash") => Some("release.yml"),
276 _ => None,
277 }
278 }
279
280 #[must_use]
283 pub fn artifact_workflow(&self) -> Option<&'static str> {
284 if !self.artifacts {
285 return None;
286 }
287 match (self.forge, self.driver.as_str()) {
288 (Forge::Github, "rust" | "bash" | "python") => Some("release.yml"),
289 _ => None,
290 }
291 }
292
293 #[must_use]
295 pub fn is_base(&self, branch: &str) -> bool {
296 branch == self.trunk || crate::release_engine::is_line(&self.line_prefix, branch)
297 }
298
299 #[must_use]
301 pub fn registry(&self) -> Option<Registry> {
302 registry_for(&self.driver, self.registry_answer)
303 }
304}
305
306#[must_use]
314pub fn registry_for(
315 driver: &str,
316 answer: Option<crate::profile::RegistryAnswer>,
317) -> Option<Registry> {
318 use crate::profile::RegistryAnswer;
319 match answer.or_else(|| RegistryAnswer::default_for(driver))? {
320 RegistryAnswer::CratesIo if driver == "rust" => Some(Registry::CratesIo),
321 RegistryAnswer::Pypi if driver == "python" => Some(Registry::Pypi),
322 _ => None,
323 }
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub enum Registry {
329 CratesIo,
331 Pypi,
333}
334
335impl Registry {
336 #[must_use]
339 pub fn url(self, package: &str, version: Option<&str>) -> String {
340 match (self, version) {
341 (Self::CratesIo, Some(version)) => {
342 format!("https://crates.io/api/v1/crates/{package}/{version}")
343 }
344 (Self::CratesIo, None) => format!("https://crates.io/api/v1/crates/{package}"),
345 (Self::Pypi, Some(version)) => {
346 format!("https://pypi.org/pypi/{package}/{version}/json")
347 }
348 (Self::Pypi, None) => format!("https://pypi.org/pypi/{package}/json"),
349 }
350 }
351}
352
353#[must_use]
357pub fn proposed_version(title: &str) -> Option<String> {
358 title
359 .split_whitespace()
360 .rev()
361 .map(|token| token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '.'))
362 .map(|token| token.strip_prefix('v').unwrap_or(token))
363 .find(|token| is_version(token))
364 .map(str::to_owned)
365}
366
367#[must_use]
370pub fn is_version(text: &str) -> bool {
371 let core = text.split_once('-').map_or(text, |(core, _)| core);
372 let parts: Vec<&str> = core.split('.').collect();
373 parts.len() == 3
374 && parts
375 .iter()
376 .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
377}
378
379#[must_use]
382pub fn newest_release<'a>(tags: impl IntoIterator<Item = &'a str>) -> Option<String> {
383 let mut newest: Option<&str> = None;
384 for tag in tags {
385 let Some(version) = tag.strip_prefix('v') else {
386 continue;
387 };
388 if !is_version(version) || version.contains('-') {
389 continue;
390 }
391 if newest.is_none_or(|current| version_is_newer(version, ¤t[1..])) {
392 newest = Some(tag);
393 }
394 }
395 newest.map(str::to_owned)
396}
397
398#[must_use]
400pub fn github_checks(body: &Value) -> Checks {
401 let Some(runs) = body["check_runs"].as_array() else {
402 return Checks::Unknown;
403 };
404 if runs.is_empty() {
405 return Checks::Pending;
406 }
407 let failed = runs.iter().any(|run| {
408 matches!(
409 run["conclusion"].as_str(),
410 Some(
411 "failure"
412 | "cancelled"
413 | "timed_out"
414 | "action_required"
415 | "startup_failure"
416 | "stale"
417 )
418 )
419 });
420 if failed {
421 return Checks::Failing;
422 }
423 if runs
424 .iter()
425 .any(|run| run["status"].as_str() != Some("completed"))
426 {
427 return Checks::Pending;
428 }
429 Checks::Passing
430}
431
432#[must_use]
434pub fn gitlab_checks(status: Option<&str>) -> Checks {
435 match status {
436 Some("success") => Checks::Passing,
437 Some("failed" | "canceled" | "canceling") => Checks::Failing,
438 Some(_) | None => Checks::Pending,
439 }
440}
441
442#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
444pub struct Run {
445 pub id: u64,
447 pub status: String,
450 pub conclusion: Option<String>,
452 pub head_branch: Option<String>,
454}
455
456impl Run {
457 #[must_use]
459 pub const fn concluded(&self) -> bool {
460 self.conclusion.is_some()
461 }
462
463 #[must_use]
465 pub fn succeeded(&self) -> bool {
466 self.conclusion.as_deref() == Some("success")
467 }
468}
469
470#[must_use]
474pub fn github_run_on(body: &Value, workflow: &str, sha: &str) -> Option<Run> {
475 let suffix = format!("/{workflow}");
476 body["workflow_runs"]
477 .as_array()?
478 .iter()
479 .filter(|run| run["head_sha"].as_str() == Some(sha))
480 .filter(|run| run["event"].as_str() == Some("push"))
481 .filter(|run| {
482 run["path"]
483 .as_str()
484 .is_some_and(|path| path.ends_with(&suffix) || path == workflow)
485 })
486 .map(|run| Run {
487 id: run["id"].as_u64().unwrap_or_default(),
488 status: run["status"].as_str().unwrap_or("unknown").to_owned(),
489 conclusion: (run["status"].as_str() == Some("completed"))
490 .then(|| run["conclusion"].as_str().map(str::to_owned))
491 .flatten(),
492 head_branch: run["head_branch"].as_str().map(str::to_owned),
493 })
494 .min_by_key(|run| run.id)
495}
496
497#[must_use]
499pub fn gitlab_pipeline_on(body: &Value, reference: impl Fn(&str) -> bool) -> Option<Run> {
500 gitlab_pipeline_where(body, |pipeline| {
501 pipeline["ref"].as_str().is_some_and(&reference)
502 })
503}
504
505#[must_use]
512pub fn gitlab_branch_push_on(body: &Value, sha: &str, base: impl Fn(&str) -> bool) -> Option<Run> {
513 gitlab_pipeline_where(body, |pipeline| {
514 pipeline["sha"].as_str() == Some(sha)
515 && pipeline["source"].as_str() == Some("push")
516 && pipeline["ref"].as_str().is_some_and(&base)
517 })
518}
519
520fn gitlab_pipeline_where(body: &Value, keep: impl Fn(&Value) -> bool) -> Option<Run> {
522 body.as_array()?
523 .iter()
524 .filter(|pipeline| keep(pipeline))
525 .map(|pipeline| {
526 let status = pipeline["status"].as_str().unwrap_or("unknown").to_owned();
527 let conclusion = matches!(
528 status.as_str(),
529 "success" | "failed" | "canceled" | "skipped"
530 )
531 .then(|| status.clone());
532 Run {
533 id: pipeline["id"].as_u64().unwrap_or_default(),
534 status,
535 conclusion,
536 head_branch: pipeline["ref"].as_str().map(str::to_owned),
537 }
538 })
539 .min_by_key(|run| run.id)
540}
541
542pub const BUNDLE_SUFFIX: &str = ".sigstore.json";
545
546#[must_use]
550pub fn is_support(name: &str) -> bool {
551 name == crate::release_engine::CHECKSUMS || name.ends_with(BUNDLE_SUFFIX)
552}
553
554#[derive(Debug, Clone, Default, PartialEq, Eq)]
556pub struct Page {
557 pub exists: bool,
559 pub assets: Vec<String>,
561 pub unattested: Vec<String>,
563 pub attestation_unknown: bool,
565}
566
567impl Page {
568 #[must_use]
570 pub const fn verified(&self) -> Answer {
571 if !self.exists || self.assets.is_empty() || !self.unattested.is_empty() {
572 return Answer::No;
573 }
574 if self.attestation_unknown {
575 return Answer::Unknown;
576 }
577 Answer::Yes
578 }
579}
580
581#[derive(Debug, Clone)]
583pub struct Merged {
584 pub number: u64,
586 pub version: String,
588 pub tagged: Answer,
590 pub published: Answer,
592}
593
594#[must_use]
605pub fn symptoms(
606 merged: Option<&Merged>,
607 last: &LastRelease,
608 page: Option<&Page>,
609 artifacts: bool,
610) -> Vec<Symptom> {
611 let mut found = Vec::new();
612 if let Some(merged) = merged {
613 let missing = match (merged.tagged, merged.published) {
614 (Answer::No, Answer::Yes) => Some(format!(
619 "no tag v{0} exists, and the registry already holds {0}, so the version cannot return to its previous value",
620 merged.version
621 )),
622 (Answer::No, _) => Some(format!("no tag v{} exists", merged.version)),
623 (_, Answer::No) => Some(format!("{} is not published", merged.version)),
624 _ => None,
625 };
626 if let Some(missing) = missing {
627 found.push(Symptom {
628 code: MERGED_NOTHING_PUBLISHED,
629 evidence: format!(
630 "release request #{} proposing {} merged, and {missing}",
631 merged.number, merged.version
632 ),
633 recovery: RECOVERY_NOTHING_PUBLISHED,
634 });
635 }
636 }
637 if let (true, Some(tag), Answer::Yes, Some(page)) =
638 (artifacts, last.tag.as_deref(), last.published, page)
639 {
640 if page.assets.is_empty() {
641 found.push(Symptom {
642 code: PUBLISHED_NO_ARTIFACTS,
643 evidence: format!("{tag} is published and its release page carries no artifact"),
644 recovery: RECOVERY_ARTIFACT_BUILD,
645 });
646 } else if !page.unattested.is_empty() {
647 found.push(Symptom {
648 code: ARTIFACT_WITHOUT_ATTESTATION,
649 evidence: format!(
650 "{tag} carries {} with no attestation",
651 page.unattested.join(", ")
652 ),
653 recovery: RECOVERY_ARTIFACT_BUILD,
654 });
655 }
656 }
657 found
658}
659
660pub fn orphan_tags(
672 tags: &[&str],
673 lines: &[&str],
674 mut complete: impl FnMut(&str) -> Answer,
675) -> Vec<Symptom> {
676 let line_of = |version: &str| {
677 lines
678 .iter()
679 .find(|line| version.starts_with(&format!("{line}.")))
680 .copied()
681 };
682 let mut groups: std::collections::BTreeMap<Option<&str>, Vec<&str>> =
683 std::collections::BTreeMap::new();
684 for tag in tags {
685 let Some(version) = tag.strip_prefix('v') else {
686 continue;
687 };
688 if !is_version(version) || version.contains('-') {
689 continue;
690 }
691 groups.entry(line_of(version)).or_default().push(tag);
692 }
693 let mut found = Vec::new();
694 for (line, mut group) in groups {
695 group.sort_by(|left, right| {
696 if version_is_newer(&left[1..], &right[1..]) {
697 std::cmp::Ordering::Less
698 } else if version_is_newer(&right[1..], &left[1..]) {
699 std::cmp::Ordering::Greater
700 } else {
701 std::cmp::Ordering::Equal
702 }
703 });
704 let place = line.map_or_else(|| "the trunk".to_owned(), |line| format!("the line {line}"));
705 for tag in group {
706 if complete(tag) != Answer::No {
709 break;
710 }
711 found.push(Symptom {
712 code: TAG_WITHOUT_RELEASE,
713 evidence: format!(
714 "{tag} exists on {place} with no complete release page, and no complete release of that line is newer"
715 ),
716 recovery: RECOVERY_TAG_WITHOUT_RELEASE,
717 });
718 }
719 }
720 found
721}
722
723#[must_use]
736pub fn local_tag_drift(local: &[(&str, &str)], forge: &[(&str, &str)]) -> Vec<Symptom> {
737 let mut local: Vec<(&str, &str)> = local
738 .iter()
739 .filter(|(tag, _)| tag.strip_prefix('v').is_some_and(is_version))
740 .copied()
741 .collect();
742 local.sort_unstable();
743 local
744 .into_iter()
745 .filter_map(|(tag, commit)| {
746 match forge.iter().find(|(name, _)| *name == tag) {
747 Some((_, held)) if *held == commit => None,
748 Some((_, held)) => Some(Symptom {
749 code: LOCAL_TAG_DIFFERS,
750 evidence: format!(
751 "the clone holds {tag} at {commit} and the forge holds it at {held}; the forge holds the true release tags, so refresh the clone: git fetch origin '+refs/tags/{tag}:refs/tags/{tag}'"
752 ),
753 recovery: RECOVERY_STALE_LOCAL_TAG,
754 }),
755 None => Some(Symptom {
756 code: LOCAL_TAG_NOT_ON_FORGE,
757 evidence: format!(
758 "the clone holds {tag} at {commit} and the forge holds no such tag; the forge holds the true release tags, so refresh the clone: git fetch --prune origin '+refs/tags/v*:refs/tags/v*'"
759 ),
760 recovery: RECOVERY_STALE_LOCAL_TAG,
761 }),
762 }
763 })
764 .collect()
765}
766
767#[derive(Debug)]
769pub enum Api {
770 Ok(Value),
772 Missing,
774 Failed(String),
776}
777
778#[derive(Debug, Clone)]
780pub struct ForgeCli {
781 cli: PathBuf,
782 cwd: PathBuf,
783}
784
785impl ForgeCli {
786 #[must_use]
788 pub const fn new(cli: PathBuf, cwd: PathBuf) -> Self {
789 Self { cli, cwd }
790 }
791
792 pub fn resolve(forge: Forge, target: &Utf8Path) -> Result<Self, crate::error::RkError> {
798 Ok(Self {
799 cli: crate::setup::context::resolve_cli(forge)?,
800 cwd: target.as_std_path().to_path_buf(),
801 })
802 }
803
804 #[must_use]
806 pub fn api(&self, path: &str) -> Api {
807 let answered = std::process::Command::new(&self.cli)
808 .args(["api", path])
809 .current_dir(&self.cwd)
810 .env("GH_PAGER", "")
811 .env("GLAB_PAGER", "")
812 .env("NO_COLOR", "1")
813 .output();
814 let output = match answered {
815 Ok(output) => output,
816 Err(source) => return Api::Failed(format!("the forge CLI did not run: {source}")),
817 };
818 if output.status.success() {
819 return serde_json::from_slice::<Value>(&output.stdout).map_or_else(
820 |_| Api::Failed("the forge answer did not parse as JSON".to_owned()),
821 Api::Ok,
822 );
823 }
824 let stderr = String::from_utf8_lossy(&output.stderr);
827 if stderr.contains("HTTP 404") || stderr.contains("404 Not Found") {
828 return Api::Missing;
829 }
830 Api::Failed(
831 stderr
832 .lines()
833 .rev()
834 .find(|line| !line.trim().is_empty())
835 .unwrap_or("no output")
836 .to_owned(),
837 )
838 }
839}
840
841pub fn registry_serves(
849 registry: Registry,
850 package: &str,
851 version: Option<&str>,
852) -> Result<Answer, String> {
853 let url = registry.url(package, version);
854 let answer = crate::http::send(&crate::http::Request::get(
855 &url,
856 std::time::Duration::from_secs(20),
857 ))
858 .map_err(|reason| format!("{url} gave no answer: {reason}"))?;
859 Ok(match answer.status {
860 200 => Answer::Yes,
861 404 => Answer::No,
862 _ => Answer::Unknown,
863 })
864}
865
866pub fn registry_files(
877 registry: Registry,
878 package: &str,
879 version: &str,
880) -> Result<Option<Vec<(String, String)>>, String> {
881 let answer = crate::http::send(&crate::http::Request::get(
882 ®istry.url(package, Some(version)),
883 std::time::Duration::from_secs(20),
884 ))?;
885 match answer.status {
886 404 => Ok(None),
887 200 => {
888 let value: Value = serde_json::from_slice(&answer.body)
889 .map_err(|_| "the answer is not JSON".to_owned())?;
890 Ok(Some(
891 value["urls"]
892 .as_array()
893 .into_iter()
894 .flatten()
895 .filter_map(|file| {
896 Some((
897 file["filename"].as_str()?.to_owned(),
898 file["digests"]["sha256"].as_str()?.to_owned(),
899 ))
900 })
901 .collect(),
902 ))
903 }
904 other => Err(format!("the registry answered \"{other}\"")),
905 }
906}
907
908#[must_use]
910pub fn gitlab_project(repo: &str) -> String {
911 repo.replace('/', "%2F")
912}
913
914#[cfg(test)]
915mod tests {
916 use serde_json::json;
917
918 use super::{
919 Answer, Checks, LastRelease, Merged, Page, github_checks, github_run_on, gitlab_checks,
920 local_tag_drift, newest_release, proposed_version, symptoms,
921 };
922
923 #[test]
924 fn a_title_proposes_its_last_version_token() {
925 assert_eq!(
926 proposed_version("chore: release v0.8.7").as_deref(),
927 Some("0.8.7")
928 );
929 assert_eq!(
930 proposed_version("chore(main): release 1.2.3").as_deref(),
931 Some("1.2.3")
932 );
933 assert_eq!(
934 proposed_version("chore(release): v2.0.0-rc.1").as_deref(),
935 Some("2.0.0-rc.1")
936 );
937 assert_eq!(proposed_version("chore: release"), None);
938 }
939
940 #[test]
941 fn the_newest_release_skips_candidates_and_other_tags() {
942 assert_eq!(
943 newest_release(["v0.9.0", "v0.10.0", "v0.11.0-rc.1", "latest", "v1.2"]).as_deref(),
944 Some("v0.10.0")
945 );
946 assert_eq!(newest_release(["nightly"]), None);
947 }
948
949 #[test]
950 fn check_runs_judge_failing_before_pending() {
951 assert_eq!(github_checks(&json!({"check_runs": []})), Checks::Pending);
952 assert_eq!(
953 github_checks(&json!({"check_runs": [
954 {"status": "completed", "conclusion": "success"},
955 {"status": "in_progress", "conclusion": null}
956 ]})),
957 Checks::Pending
958 );
959 assert_eq!(
960 github_checks(&json!({"check_runs": [
961 {"status": "completed", "conclusion": "failure"},
962 {"status": "in_progress", "conclusion": null}
963 ]})),
964 Checks::Failing
965 );
966 assert_eq!(
967 github_checks(&json!({"check_runs": [
968 {"status": "completed", "conclusion": "success"},
969 {"status": "completed", "conclusion": "skipped"}
970 ]})),
971 Checks::Passing
972 );
973 assert_eq!(github_checks(&json!({})), Checks::Unknown);
974 assert_eq!(gitlab_checks(Some("running")), Checks::Pending);
975 assert_eq!(gitlab_checks(Some("failed")), Checks::Failing);
976 assert_eq!(gitlab_checks(Some("success")), Checks::Passing);
977 }
978
979 #[test]
982 fn a_run_is_selected_by_its_commit_never_by_recency() {
983 let body = json!({"workflow_runs": [
984 {"id": 30, "head_sha": "bbb", "event": "pull_request", "status": "in_progress",
985 "conclusion": null, "path": ".github/workflows/release-plz.yml", "head_branch": "feat/x"},
986 {"id": 20, "head_sha": "aaa", "event": "push", "status": "completed",
987 "conclusion": "success", "path": ".github/workflows/release-plz.yml", "head_branch": "master"},
988 {"id": 21, "head_sha": "aaa", "event": "push", "status": "queued",
989 "conclusion": null, "path": ".github/workflows/release.yml", "head_branch": "v1.0.0"}
990 ]});
991 let publish = github_run_on(&body, "release-plz.yml", "aaa").expect("the publish run");
992 assert_eq!(publish.id, 20);
993 assert!(publish.succeeded());
994 let artifact = github_run_on(&body, "release.yml", "aaa").expect("the artifact run");
995 assert_eq!(artifact.id, 21);
996 assert!(!artifact.concluded());
997 assert_eq!(github_run_on(&body, "release-plz.yml", "bbb"), None);
998 }
999
1000 fn last(tag: &str, published: Answer) -> LastRelease {
1001 LastRelease {
1002 tag: Some(tag.to_owned()),
1003 published,
1004 artifacts_verified: Answer::Unknown,
1005 }
1006 }
1007
1008 #[test]
1009 fn each_symptom_has_its_rule() {
1010 let merged = Merged {
1011 number: 12,
1012 version: "1.2.3".into(),
1013 tagged: Answer::No,
1014 published: Answer::Unknown,
1015 };
1016 let found = symptoms(Some(&merged), &last("v1.2.2", Answer::Yes), None, false);
1017 assert_eq!(found.len(), 1);
1018 assert_eq!(found[0].code, super::MERGED_NOTHING_PUBLISHED);
1019 assert_eq!(
1020 found[0].recovery,
1021 "The release request merged and nothing published"
1022 );
1023 let unpublished = Merged {
1024 tagged: Answer::Yes,
1025 published: Answer::No,
1026 ..merged.clone()
1027 };
1028 assert_eq!(
1029 symptoms(Some(&unpublished), &last("v1.2.3", Answer::No), None, false)[0].code,
1030 super::MERGED_NOTHING_PUBLISHED
1031 );
1032 let shipped = Merged {
1033 tagged: Answer::Yes,
1034 published: Answer::Yes,
1035 ..merged
1036 };
1037 let empty = Page {
1038 exists: true,
1039 ..Page::default()
1040 };
1041 let found = symptoms(
1042 Some(&shipped),
1043 &last("v1.2.3", Answer::Yes),
1044 Some(&empty),
1045 true,
1046 );
1047 assert_eq!(found.len(), 1);
1048 assert_eq!(found[0].code, super::PUBLISHED_NO_ARTIFACTS);
1049 assert_eq!(found[0].recovery, "The artifact build failed");
1050 let unattested = Page {
1051 exists: true,
1052 assets: vec!["widget.tar.gz".into()],
1053 unattested: vec!["widget.tar.gz".into()],
1054 attestation_unknown: false,
1055 };
1056 let found = symptoms(None, &last("v1.2.3", Answer::Yes), Some(&unattested), true);
1057 assert_eq!(found[0].code, super::ARTIFACT_WITHOUT_ATTESTATION);
1058 assert!(found[0].evidence.contains("widget.tar.gz"));
1059 assert!(
1060 symptoms(None, &last("v1.2.3", Answer::Yes), Some(&empty), false).is_empty(),
1061 "a pair that builds no artifact has no artifact symptom"
1062 );
1063 }
1064
1065 #[test]
1068 fn an_untagged_version_the_registry_holds_says_so() {
1069 let merged = Merged {
1070 number: 12,
1071 version: "1.2.3".into(),
1072 tagged: Answer::No,
1073 published: Answer::Yes,
1074 };
1075 let found = symptoms(Some(&merged), &last("v1.2.2", Answer::Yes), None, false);
1076 assert_eq!(found.len(), 1);
1077 assert_eq!(found[0].code, super::MERGED_NOTHING_PUBLISHED);
1078 assert!(
1079 found[0]
1080 .evidence
1081 .contains("the registry already holds 1.2.3"),
1082 "{}",
1083 found[0].evidence
1084 );
1085 assert_eq!(
1086 found[0].recovery,
1087 "The release request merged and nothing published"
1088 );
1089 let unpublished = Merged {
1090 published: Answer::No,
1091 ..merged
1092 };
1093 let found = symptoms(
1094 Some(&unpublished),
1095 &last("v1.2.2", Answer::Yes),
1096 None,
1097 false,
1098 );
1099 assert_eq!(found[0].code, super::MERGED_NOTHING_PUBLISHED);
1100 assert!(
1101 !found[0].evidence.contains("registry"),
1102 "{}",
1103 found[0].evidence
1104 );
1105 }
1106
1107 #[test]
1108 fn local_tag_drift_reports_a_moved_and_a_local_only_tag() {
1109 let found = local_tag_drift(
1110 &[("v0.3.0", "ccc"), ("v0.1.0", "aaa"), ("v0.2.0", "bbb")],
1111 &[("v0.1.0", "fff"), ("v0.3.0", "ccc"), ("v0.4.0", "ddd")],
1112 );
1113 let codes: Vec<&str> = found.iter().map(|symptom| symptom.code).collect();
1114 assert_eq!(
1115 codes,
1116 [super::LOCAL_TAG_DIFFERS, super::LOCAL_TAG_NOT_ON_FORGE],
1117 "the moved tag and the local-only tag, in tag order: {found:?}"
1118 );
1119 assert!(found[0].evidence.contains("v0.1.0 at aaa"));
1120 assert!(found[0].evidence.contains("holds it at fff"));
1121 assert!(found[1].evidence.contains("v0.2.0 at bbb"));
1122 assert!(
1123 found
1124 .iter()
1125 .all(|symptom| symptom.recovery == "A clone holds a stale release tag")
1126 );
1127 }
1128
1129 #[test]
1130 fn the_answer_words_are_the_wire_form() {
1131 for (answer, word) in [
1132 (Answer::Yes, "yes"),
1133 (Answer::No, "no"),
1134 (Answer::Unknown, "unknown"),
1135 (Answer::NotApplicable, "not-applicable"),
1136 ] {
1137 assert_eq!(answer.as_str(), word);
1138 assert_eq!(
1139 serde_json::to_string(&answer).expect("serializes"),
1140 format!("\"{word}\"")
1141 );
1142 }
1143 assert_eq!(Answer::of(true), Answer::Yes);
1144 }
1145}