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 TAG_WITHOUT_RELEASE: &str = "tag-without-release";
162
163const RECOVERY_TAG_WITHOUT_RELEASE: &str = "The tag exists and the release does not";
166
167pub const LOCAL_TAG_DIFFERS: &str = "local-tag-differs";
170pub const LOCAL_TAG_NOT_ON_FORGE: &str = "local-tag-not-on-forge";
172
173const RECOVERY_STALE_LOCAL_TAG: &str = "A clone holds a stale release tag";
176
177const RECOVERY_NOTHING_PUBLISHED: &str = "The release request merged and nothing published";
179const RECOVERY_ARTIFACT_BUILD: &str = "The artifact build failed";
182
183#[derive(Debug, Clone)]
185pub struct Pair {
186 pub driver: String,
188 pub forge: Forge,
190 pub repo: String,
192 pub trunk: String,
194 pub line_prefix: String,
196 pub artifacts: bool,
198 pub package: Option<String>,
200 pub registry_answer: Option<crate::profile::RegistryAnswer>,
203}
204
205impl Pair {
206 #[must_use]
208 pub fn request_prefix(&self) -> Option<&'static str> {
209 match self.driver.as_str() {
210 "rust" => Some("release-plz-"),
211 "python" | "bash" => Some(crate::release_engine::REQUEST_PREFIX),
213 _ => None,
214 }
215 }
216
217 #[must_use]
219 pub fn publish_workflow(&self) -> Option<&'static str> {
220 match (self.forge, self.driver.as_str()) {
221 (Forge::Github, "rust") => Some("release-plz.yml"),
222 (Forge::Github, "python" | "bash") => Some("release.yml"),
223 _ => None,
224 }
225 }
226
227 #[must_use]
230 pub fn artifact_workflow(&self) -> Option<&'static str> {
231 if !self.artifacts {
232 return None;
233 }
234 match (self.forge, self.driver.as_str()) {
235 (Forge::Github, "rust" | "bash" | "python") => Some("release.yml"),
236 _ => None,
237 }
238 }
239
240 #[must_use]
242 pub fn is_base(&self, branch: &str) -> bool {
243 branch == self.trunk || crate::release_engine::is_line(&self.line_prefix, branch)
244 }
245
246 #[must_use]
248 pub fn registry(&self) -> Option<Registry> {
249 registry_for(&self.driver, self.registry_answer)
250 }
251}
252
253#[must_use]
261pub fn registry_for(
262 driver: &str,
263 answer: Option<crate::profile::RegistryAnswer>,
264) -> Option<Registry> {
265 use crate::profile::RegistryAnswer;
266 match answer.or_else(|| RegistryAnswer::default_for(driver))? {
267 RegistryAnswer::CratesIo if driver == "rust" => Some(Registry::CratesIo),
268 RegistryAnswer::Pypi if driver == "python" => Some(Registry::Pypi),
269 _ => None,
270 }
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub enum Registry {
276 CratesIo,
278 Pypi,
280}
281
282impl Registry {
283 #[must_use]
286 pub fn url(self, package: &str, version: Option<&str>) -> String {
287 match (self, version) {
288 (Self::CratesIo, Some(version)) => {
289 format!("https://crates.io/api/v1/crates/{package}/{version}")
290 }
291 (Self::CratesIo, None) => format!("https://crates.io/api/v1/crates/{package}"),
292 (Self::Pypi, Some(version)) => {
293 format!("https://pypi.org/pypi/{package}/{version}/json")
294 }
295 (Self::Pypi, None) => format!("https://pypi.org/pypi/{package}/json"),
296 }
297 }
298}
299
300#[must_use]
304pub fn proposed_version(title: &str) -> Option<String> {
305 title
306 .split_whitespace()
307 .rev()
308 .map(|token| token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '.'))
309 .map(|token| token.strip_prefix('v').unwrap_or(token))
310 .find(|token| is_version(token))
311 .map(str::to_owned)
312}
313
314#[must_use]
317pub fn is_version(text: &str) -> bool {
318 let core = text.split_once('-').map_or(text, |(core, _)| core);
319 let parts: Vec<&str> = core.split('.').collect();
320 parts.len() == 3
321 && parts
322 .iter()
323 .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
324}
325
326#[must_use]
329pub fn newest_release<'a>(tags: impl IntoIterator<Item = &'a str>) -> Option<String> {
330 let mut newest: Option<&str> = None;
331 for tag in tags {
332 let Some(version) = tag.strip_prefix('v') else {
333 continue;
334 };
335 if !is_version(version) || version.contains('-') {
336 continue;
337 }
338 if newest.is_none_or(|current| version_is_newer(version, ¤t[1..])) {
339 newest = Some(tag);
340 }
341 }
342 newest.map(str::to_owned)
343}
344
345#[must_use]
347pub fn github_checks(body: &Value) -> Checks {
348 let Some(runs) = body["check_runs"].as_array() else {
349 return Checks::Unknown;
350 };
351 if runs.is_empty() {
352 return Checks::Pending;
353 }
354 let failed = runs.iter().any(|run| {
355 matches!(
356 run["conclusion"].as_str(),
357 Some(
358 "failure"
359 | "cancelled"
360 | "timed_out"
361 | "action_required"
362 | "startup_failure"
363 | "stale"
364 )
365 )
366 });
367 if failed {
368 return Checks::Failing;
369 }
370 if runs
371 .iter()
372 .any(|run| run["status"].as_str() != Some("completed"))
373 {
374 return Checks::Pending;
375 }
376 Checks::Passing
377}
378
379#[must_use]
381pub fn gitlab_checks(status: Option<&str>) -> Checks {
382 match status {
383 Some("success") => Checks::Passing,
384 Some("failed" | "canceled" | "canceling") => Checks::Failing,
385 Some(_) | None => Checks::Pending,
386 }
387}
388
389#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
391pub struct Run {
392 pub id: u64,
394 pub status: String,
397 pub conclusion: Option<String>,
399 pub head_branch: Option<String>,
401}
402
403impl Run {
404 #[must_use]
406 pub const fn concluded(&self) -> bool {
407 self.conclusion.is_some()
408 }
409
410 #[must_use]
412 pub fn succeeded(&self) -> bool {
413 self.conclusion.as_deref() == Some("success")
414 }
415}
416
417#[must_use]
421pub fn github_run_on(body: &Value, workflow: &str, sha: &str) -> Option<Run> {
422 let suffix = format!("/{workflow}");
423 body["workflow_runs"]
424 .as_array()?
425 .iter()
426 .filter(|run| run["head_sha"].as_str() == Some(sha))
427 .filter(|run| run["event"].as_str() == Some("push"))
428 .filter(|run| {
429 run["path"]
430 .as_str()
431 .is_some_and(|path| path.ends_with(&suffix) || path == workflow)
432 })
433 .map(|run| Run {
434 id: run["id"].as_u64().unwrap_or_default(),
435 status: run["status"].as_str().unwrap_or("unknown").to_owned(),
436 conclusion: (run["status"].as_str() == Some("completed"))
437 .then(|| run["conclusion"].as_str().map(str::to_owned))
438 .flatten(),
439 head_branch: run["head_branch"].as_str().map(str::to_owned),
440 })
441 .min_by_key(|run| run.id)
442}
443
444#[must_use]
446pub fn gitlab_pipeline_on(body: &Value, reference: impl Fn(&str) -> bool) -> Option<Run> {
447 gitlab_pipeline_where(body, |pipeline| {
448 pipeline["ref"].as_str().is_some_and(&reference)
449 })
450}
451
452#[must_use]
459pub fn gitlab_branch_push_on(body: &Value, sha: &str, base: impl Fn(&str) -> bool) -> Option<Run> {
460 gitlab_pipeline_where(body, |pipeline| {
461 pipeline["sha"].as_str() == Some(sha)
462 && pipeline["source"].as_str() == Some("push")
463 && pipeline["ref"].as_str().is_some_and(&base)
464 })
465}
466
467fn gitlab_pipeline_where(body: &Value, keep: impl Fn(&Value) -> bool) -> Option<Run> {
469 body.as_array()?
470 .iter()
471 .filter(|pipeline| keep(pipeline))
472 .map(|pipeline| {
473 let status = pipeline["status"].as_str().unwrap_or("unknown").to_owned();
474 let conclusion = matches!(
475 status.as_str(),
476 "success" | "failed" | "canceled" | "skipped"
477 )
478 .then(|| status.clone());
479 Run {
480 id: pipeline["id"].as_u64().unwrap_or_default(),
481 status,
482 conclusion,
483 head_branch: pipeline["ref"].as_str().map(str::to_owned),
484 }
485 })
486 .min_by_key(|run| run.id)
487}
488
489pub const BUNDLE_SUFFIX: &str = ".sigstore.json";
492
493#[must_use]
497pub fn is_support(name: &str) -> bool {
498 name == crate::release_engine::CHECKSUMS || name.ends_with(BUNDLE_SUFFIX)
499}
500
501#[derive(Debug, Clone, Default, PartialEq, Eq)]
503pub struct Page {
504 pub exists: bool,
506 pub assets: Vec<String>,
508 pub unattested: Vec<String>,
510 pub attestation_unknown: bool,
512}
513
514impl Page {
515 #[must_use]
517 pub const fn verified(&self) -> Answer {
518 if !self.exists || self.assets.is_empty() || !self.unattested.is_empty() {
519 return Answer::No;
520 }
521 if self.attestation_unknown {
522 return Answer::Unknown;
523 }
524 Answer::Yes
525 }
526}
527
528#[derive(Debug, Clone)]
530pub struct Merged {
531 pub number: u64,
533 pub version: String,
535 pub tagged: Answer,
537 pub published: Answer,
539}
540
541#[must_use]
552pub fn symptoms(
553 merged: Option<&Merged>,
554 last: &LastRelease,
555 page: Option<&Page>,
556 artifacts: bool,
557) -> Vec<Symptom> {
558 let mut found = Vec::new();
559 if let Some(merged) = merged {
560 let missing = match (merged.tagged, merged.published) {
561 (Answer::No, Answer::Yes) => Some(format!(
566 "no tag v{0} exists, and the registry already holds {0}, so the version cannot return to its previous value",
567 merged.version
568 )),
569 (Answer::No, _) => Some(format!("no tag v{} exists", merged.version)),
570 (_, Answer::No) => Some(format!("{} is not published", merged.version)),
571 _ => None,
572 };
573 if let Some(missing) = missing {
574 found.push(Symptom {
575 code: MERGED_NOTHING_PUBLISHED,
576 evidence: format!(
577 "release request #{} proposing {} merged, and {missing}",
578 merged.number, merged.version
579 ),
580 recovery: RECOVERY_NOTHING_PUBLISHED,
581 });
582 }
583 }
584 if let (true, Some(tag), Answer::Yes, Some(page)) =
585 (artifacts, last.tag.as_deref(), last.published, page)
586 {
587 if page.assets.is_empty() {
588 found.push(Symptom {
589 code: PUBLISHED_NO_ARTIFACTS,
590 evidence: format!("{tag} is published and its release page carries no artifact"),
591 recovery: RECOVERY_ARTIFACT_BUILD,
592 });
593 } else if !page.unattested.is_empty() {
594 found.push(Symptom {
595 code: ARTIFACT_WITHOUT_ATTESTATION,
596 evidence: format!(
597 "{tag} carries {} with no attestation",
598 page.unattested.join(", ")
599 ),
600 recovery: RECOVERY_ARTIFACT_BUILD,
601 });
602 }
603 }
604 found
605}
606
607pub fn orphan_tags(
619 tags: &[&str],
620 lines: &[&str],
621 mut complete: impl FnMut(&str) -> Answer,
622) -> Vec<Symptom> {
623 let line_of = |version: &str| {
624 lines
625 .iter()
626 .find(|line| version.starts_with(&format!("{line}.")))
627 .copied()
628 };
629 let mut groups: std::collections::BTreeMap<Option<&str>, Vec<&str>> =
630 std::collections::BTreeMap::new();
631 for tag in tags {
632 let Some(version) = tag.strip_prefix('v') else {
633 continue;
634 };
635 if !is_version(version) || version.contains('-') {
636 continue;
637 }
638 groups.entry(line_of(version)).or_default().push(tag);
639 }
640 let mut found = Vec::new();
641 for (line, mut group) in groups {
642 group.sort_by(|left, right| {
643 if version_is_newer(&left[1..], &right[1..]) {
644 std::cmp::Ordering::Less
645 } else if version_is_newer(&right[1..], &left[1..]) {
646 std::cmp::Ordering::Greater
647 } else {
648 std::cmp::Ordering::Equal
649 }
650 });
651 let place = line.map_or_else(|| "the trunk".to_owned(), |line| format!("the line {line}"));
652 for tag in group {
653 if complete(tag) != Answer::No {
656 break;
657 }
658 found.push(Symptom {
659 code: TAG_WITHOUT_RELEASE,
660 evidence: format!(
661 "{tag} exists on {place} with no complete release page, and no complete release of that line is newer"
662 ),
663 recovery: RECOVERY_TAG_WITHOUT_RELEASE,
664 });
665 }
666 }
667 found
668}
669
670#[must_use]
683pub fn local_tag_drift(local: &[(&str, &str)], forge: &[(&str, &str)]) -> Vec<Symptom> {
684 let mut local: Vec<(&str, &str)> = local
685 .iter()
686 .filter(|(tag, _)| tag.strip_prefix('v').is_some_and(is_version))
687 .copied()
688 .collect();
689 local.sort_unstable();
690 local
691 .into_iter()
692 .filter_map(|(tag, commit)| {
693 match forge.iter().find(|(name, _)| *name == tag) {
694 Some((_, held)) if *held == commit => None,
695 Some((_, held)) => Some(Symptom {
696 code: LOCAL_TAG_DIFFERS,
697 evidence: format!(
698 "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}'"
699 ),
700 recovery: RECOVERY_STALE_LOCAL_TAG,
701 }),
702 None => Some(Symptom {
703 code: LOCAL_TAG_NOT_ON_FORGE,
704 evidence: format!(
705 "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*'"
706 ),
707 recovery: RECOVERY_STALE_LOCAL_TAG,
708 }),
709 }
710 })
711 .collect()
712}
713
714#[derive(Debug)]
716pub enum Api {
717 Ok(Value),
719 Missing,
721 Failed(String),
723}
724
725#[derive(Debug, Clone)]
727pub struct ForgeCli {
728 cli: PathBuf,
729 cwd: PathBuf,
730}
731
732impl ForgeCli {
733 #[must_use]
735 pub const fn new(cli: PathBuf, cwd: PathBuf) -> Self {
736 Self { cli, cwd }
737 }
738
739 pub fn resolve(forge: Forge, target: &Utf8Path) -> Result<Self, crate::error::RkError> {
745 Ok(Self {
746 cli: crate::setup::context::resolve_cli(forge)?,
747 cwd: target.as_std_path().to_path_buf(),
748 })
749 }
750
751 #[must_use]
753 pub fn api(&self, path: &str) -> Api {
754 let answered = std::process::Command::new(&self.cli)
755 .args(["api", path])
756 .current_dir(&self.cwd)
757 .env("GH_PAGER", "")
758 .env("GLAB_PAGER", "")
759 .env("NO_COLOR", "1")
760 .output();
761 let output = match answered {
762 Ok(output) => output,
763 Err(source) => return Api::Failed(format!("the forge CLI did not run: {source}")),
764 };
765 if output.status.success() {
766 return serde_json::from_slice::<Value>(&output.stdout).map_or_else(
767 |_| Api::Failed("the forge answer did not parse as JSON".to_owned()),
768 Api::Ok,
769 );
770 }
771 let stderr = String::from_utf8_lossy(&output.stderr);
774 if stderr.contains("HTTP 404") || stderr.contains("404 Not Found") {
775 return Api::Missing;
776 }
777 Api::Failed(
778 stderr
779 .lines()
780 .rev()
781 .find(|line| !line.trim().is_empty())
782 .unwrap_or("no output")
783 .to_owned(),
784 )
785 }
786}
787
788#[must_use]
792pub fn registry_serves(registry: Registry, package: &str, version: Option<&str>) -> Answer {
793 let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
794 let answered = std::process::Command::new(curl)
795 .args([
796 "-sS",
797 "-o",
798 "/dev/null",
799 "-w",
800 "%{http_code}",
801 "--max-time",
802 "20",
803 ])
804 .args(["-A", concat!("release-kit/", env!("CARGO_PKG_VERSION"))])
805 .arg(registry.url(package, version))
806 .output();
807 match answered {
808 Ok(output) => match String::from_utf8_lossy(&output.stdout).trim() {
809 "200" => Answer::Yes,
810 "404" => Answer::No,
811 _ => Answer::Unknown,
812 },
813 Err(_) => Answer::Unknown,
814 }
815}
816
817pub fn registry_files(
828 registry: Registry,
829 package: &str,
830 version: &str,
831) -> Result<Option<Vec<(String, String)>>, String> {
832 let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
833 let output = std::process::Command::new(curl)
834 .args(["-sS", "-w", "\n%{http_code}", "--max-time", "20"])
835 .args(["-A", concat!("release-kit/", env!("CARGO_PKG_VERSION"))])
836 .arg(registry.url(package, Some(version)))
837 .output()
838 .map_err(|source| format!("curl did not run: {source}"))?;
839 let text = String::from_utf8_lossy(&output.stdout);
840 let (body, code) = text
841 .trim_end()
842 .rsplit_once('\n')
843 .unwrap_or_else(|| ("", text.trim()));
844 match code.trim() {
845 "404" => Ok(None),
846 "200" => {
847 let value: Value =
848 serde_json::from_str(body).map_err(|_| "the answer is not JSON".to_owned())?;
849 Ok(Some(
850 value["urls"]
851 .as_array()
852 .into_iter()
853 .flatten()
854 .filter_map(|file| {
855 Some((
856 file["filename"].as_str()?.to_owned(),
857 file["digests"]["sha256"].as_str()?.to_owned(),
858 ))
859 })
860 .collect(),
861 ))
862 }
863 other => Err(format!("the registry answered {other:?}")),
864 }
865}
866
867#[must_use]
869pub fn gitlab_project(repo: &str) -> String {
870 repo.replace('/', "%2F")
871}
872
873#[cfg(test)]
874mod tests {
875 use serde_json::json;
876
877 use super::{
878 Answer, Checks, LastRelease, Merged, Page, github_checks, github_run_on, gitlab_checks,
879 local_tag_drift, newest_release, proposed_version, symptoms,
880 };
881
882 #[test]
883 fn a_title_proposes_its_last_version_token() {
884 assert_eq!(
885 proposed_version("chore: release v0.8.7").as_deref(),
886 Some("0.8.7")
887 );
888 assert_eq!(
889 proposed_version("chore(main): release 1.2.3").as_deref(),
890 Some("1.2.3")
891 );
892 assert_eq!(
893 proposed_version("chore(release): v2.0.0-rc.1").as_deref(),
894 Some("2.0.0-rc.1")
895 );
896 assert_eq!(proposed_version("chore: release"), None);
897 }
898
899 #[test]
900 fn the_newest_release_skips_candidates_and_other_tags() {
901 assert_eq!(
902 newest_release(["v0.9.0", "v0.10.0", "v0.11.0-rc.1", "latest", "v1.2"]).as_deref(),
903 Some("v0.10.0")
904 );
905 assert_eq!(newest_release(["nightly"]), None);
906 }
907
908 #[test]
909 fn check_runs_judge_failing_before_pending() {
910 assert_eq!(github_checks(&json!({"check_runs": []})), Checks::Pending);
911 assert_eq!(
912 github_checks(&json!({"check_runs": [
913 {"status": "completed", "conclusion": "success"},
914 {"status": "in_progress", "conclusion": null}
915 ]})),
916 Checks::Pending
917 );
918 assert_eq!(
919 github_checks(&json!({"check_runs": [
920 {"status": "completed", "conclusion": "failure"},
921 {"status": "in_progress", "conclusion": null}
922 ]})),
923 Checks::Failing
924 );
925 assert_eq!(
926 github_checks(&json!({"check_runs": [
927 {"status": "completed", "conclusion": "success"},
928 {"status": "completed", "conclusion": "skipped"}
929 ]})),
930 Checks::Passing
931 );
932 assert_eq!(github_checks(&json!({})), Checks::Unknown);
933 assert_eq!(gitlab_checks(Some("running")), Checks::Pending);
934 assert_eq!(gitlab_checks(Some("failed")), Checks::Failing);
935 assert_eq!(gitlab_checks(Some("success")), Checks::Passing);
936 }
937
938 #[test]
941 fn a_run_is_selected_by_its_commit_never_by_recency() {
942 let body = json!({"workflow_runs": [
943 {"id": 30, "head_sha": "bbb", "event": "pull_request", "status": "in_progress",
944 "conclusion": null, "path": ".github/workflows/release-plz.yml", "head_branch": "feat/x"},
945 {"id": 20, "head_sha": "aaa", "event": "push", "status": "completed",
946 "conclusion": "success", "path": ".github/workflows/release-plz.yml", "head_branch": "master"},
947 {"id": 21, "head_sha": "aaa", "event": "push", "status": "queued",
948 "conclusion": null, "path": ".github/workflows/release.yml", "head_branch": "v1.0.0"}
949 ]});
950 let publish = github_run_on(&body, "release-plz.yml", "aaa").expect("the publish run");
951 assert_eq!(publish.id, 20);
952 assert!(publish.succeeded());
953 let artifact = github_run_on(&body, "release.yml", "aaa").expect("the artifact run");
954 assert_eq!(artifact.id, 21);
955 assert!(!artifact.concluded());
956 assert_eq!(github_run_on(&body, "release-plz.yml", "bbb"), None);
957 }
958
959 fn last(tag: &str, published: Answer) -> LastRelease {
960 LastRelease {
961 tag: Some(tag.to_owned()),
962 published,
963 artifacts_verified: Answer::Unknown,
964 }
965 }
966
967 #[test]
968 fn each_symptom_has_its_rule() {
969 let merged = Merged {
970 number: 12,
971 version: "1.2.3".into(),
972 tagged: Answer::No,
973 published: Answer::Unknown,
974 };
975 let found = symptoms(Some(&merged), &last("v1.2.2", Answer::Yes), None, false);
976 assert_eq!(found.len(), 1);
977 assert_eq!(found[0].code, super::MERGED_NOTHING_PUBLISHED);
978 assert_eq!(
979 found[0].recovery,
980 "The release request merged and nothing published"
981 );
982 let unpublished = Merged {
983 tagged: Answer::Yes,
984 published: Answer::No,
985 ..merged.clone()
986 };
987 assert_eq!(
988 symptoms(Some(&unpublished), &last("v1.2.3", Answer::No), None, false)[0].code,
989 super::MERGED_NOTHING_PUBLISHED
990 );
991 let shipped = Merged {
992 tagged: Answer::Yes,
993 published: Answer::Yes,
994 ..merged
995 };
996 let empty = Page {
997 exists: true,
998 ..Page::default()
999 };
1000 let found = symptoms(
1001 Some(&shipped),
1002 &last("v1.2.3", Answer::Yes),
1003 Some(&empty),
1004 true,
1005 );
1006 assert_eq!(found.len(), 1);
1007 assert_eq!(found[0].code, super::PUBLISHED_NO_ARTIFACTS);
1008 assert_eq!(found[0].recovery, "The artifact build failed");
1009 let unattested = Page {
1010 exists: true,
1011 assets: vec!["widget.tar.gz".into()],
1012 unattested: vec!["widget.tar.gz".into()],
1013 attestation_unknown: false,
1014 };
1015 let found = symptoms(None, &last("v1.2.3", Answer::Yes), Some(&unattested), true);
1016 assert_eq!(found[0].code, super::ARTIFACT_WITHOUT_ATTESTATION);
1017 assert!(found[0].evidence.contains("widget.tar.gz"));
1018 assert!(
1019 symptoms(None, &last("v1.2.3", Answer::Yes), Some(&empty), false).is_empty(),
1020 "a pair that builds no artifact has no artifact symptom"
1021 );
1022 }
1023
1024 #[test]
1027 fn an_untagged_version_the_registry_holds_says_so() {
1028 let merged = Merged {
1029 number: 12,
1030 version: "1.2.3".into(),
1031 tagged: Answer::No,
1032 published: Answer::Yes,
1033 };
1034 let found = symptoms(Some(&merged), &last("v1.2.2", Answer::Yes), None, false);
1035 assert_eq!(found.len(), 1);
1036 assert_eq!(found[0].code, super::MERGED_NOTHING_PUBLISHED);
1037 assert!(
1038 found[0]
1039 .evidence
1040 .contains("the registry already holds 1.2.3"),
1041 "{}",
1042 found[0].evidence
1043 );
1044 assert_eq!(
1045 found[0].recovery,
1046 "The release request merged and nothing published"
1047 );
1048 let unpublished = Merged {
1049 published: Answer::No,
1050 ..merged
1051 };
1052 let found = symptoms(
1053 Some(&unpublished),
1054 &last("v1.2.2", Answer::Yes),
1055 None,
1056 false,
1057 );
1058 assert_eq!(found[0].code, super::MERGED_NOTHING_PUBLISHED);
1059 assert!(
1060 !found[0].evidence.contains("registry"),
1061 "{}",
1062 found[0].evidence
1063 );
1064 }
1065
1066 #[test]
1067 fn local_tag_drift_reports_a_moved_and_a_local_only_tag() {
1068 let found = local_tag_drift(
1069 &[("v0.3.0", "ccc"), ("v0.1.0", "aaa"), ("v0.2.0", "bbb")],
1070 &[("v0.1.0", "fff"), ("v0.3.0", "ccc"), ("v0.4.0", "ddd")],
1071 );
1072 let codes: Vec<&str> = found.iter().map(|symptom| symptom.code).collect();
1073 assert_eq!(
1074 codes,
1075 [super::LOCAL_TAG_DIFFERS, super::LOCAL_TAG_NOT_ON_FORGE],
1076 "the moved tag and the local-only tag, in tag order: {found:?}"
1077 );
1078 assert!(found[0].evidence.contains("v0.1.0 at aaa"));
1079 assert!(found[0].evidence.contains("holds it at fff"));
1080 assert!(found[1].evidence.contains("v0.2.0 at bbb"));
1081 assert!(
1082 found
1083 .iter()
1084 .all(|symptom| symptom.recovery == "A clone holds a stale release tag")
1085 );
1086 }
1087
1088 #[test]
1089 fn the_answer_words_are_the_wire_form() {
1090 for (answer, word) in [
1091 (Answer::Yes, "yes"),
1092 (Answer::No, "no"),
1093 (Answer::Unknown, "unknown"),
1094 (Answer::NotApplicable, "not-applicable"),
1095 ] {
1096 assert_eq!(answer.as_str(), word);
1097 assert_eq!(
1098 serde_json::to_string(&answer).expect("serializes"),
1099 format!("\"{word}\"")
1100 );
1101 }
1102 assert_eq!(Answer::of(true), Answer::Yes);
1103 }
1104}