Skip to main content

release_kit/
release_state.rs

1//! Where a landed target's release stands: the release request, the last
2//! release, and the symptoms a stuck release shows.
3//!
4//! The judgments are pure and take forge answers as JSON values, so each
5//! rule is testable without a forge. The reads spawn the forge CLI the
6//! setup verbs resolve, `gh api` or `glab api`, and the registry reads
7//! spawn `curl`. A read that fails for any reason other than a definite
8//! not-found answers `unknown`, because a credential this runtime cannot
9//! reach is an observation boundary and never a state of the release.
10//!
11//! SATISFIES release-state:a-forge-field-reads-unknown-without-credentials
12
13use 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/// One answer a forge or a registry gives: a field the runtime could not
23/// read is `unknown`, and a field the landing makes meaningless is
24/// `not-applicable`.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum Answer {
28    /// The fact holds.
29    Yes,
30    /// The fact does not hold.
31    No,
32    /// The runtime could not read it.
33    Unknown,
34    /// The landing gives the fact no meaning, such as artifacts on a pair
35    /// that builds none.
36    NotApplicable,
37}
38
39impl Answer {
40    /// The word the JSON and the human report use.
41    #[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    /// `yes` for true, `no` for false.
52    #[must_use]
53    pub const fn of(value: bool) -> Self {
54        if value { Self::Yes } else { Self::No }
55    }
56}
57
58/// Where a request's checks stand.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60#[serde(rename_all = "kebab-case")]
61pub enum Checks {
62    /// A check is still running, or none has started.
63    Pending,
64    /// Every check concluded without a failure.
65    Passing,
66    /// A check concluded with a failure.
67    Failing,
68    /// The runtime could not read them.
69    Unknown,
70}
71
72impl Checks {
73    /// The word the JSON and the human report use.
74    #[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/// The open release request, as the forge reports it.
86#[derive(Debug, Clone, Serialize)]
87pub struct Request {
88    /// `none`, `open`, or `unknown`.
89    pub state: &'static str,
90    /// The request's number on the forge.
91    pub number: Option<u64>,
92    /// The version its title proposes.
93    pub proposed_version: Option<String>,
94    /// Whether the request merges itself once its checks pass.
95    pub armed: Answer,
96    /// Where its checks stand.
97    pub checks: Checks,
98    /// Whether the trunk moved past the commit the request was cut from.
99    pub behind_trunk: Answer,
100}
101
102impl Request {
103    /// A request the runtime could not read.
104    #[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    /// No open release request.
117    #[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/// The newest release and whether it reached its consumers.
131#[derive(Debug, Clone, Serialize)]
132pub struct LastRelease {
133    /// The newest release tag, absent while none exists.
134    pub tag: Option<String>,
135    /// Whether the registry, or the forge where the pair has no registry,
136    /// serves the version.
137    pub published: Answer,
138    /// Whether the release page carries its artifacts, each attested.
139    pub artifacts_verified: Answer,
140}
141
142/// One symptom a release shows, with the recovery section that owns it.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
144pub struct Symptom {
145    /// The stable code a reader branches on.
146    pub code: &'static str,
147    /// What was observed, one line.
148    pub evidence: String,
149    /// The `rk method recovery` section that owns the path.
150    pub recovery: &'static str,
151}
152
153/// The code for a merged release request that published nothing.
154pub const MERGED_NOTHING_PUBLISHED: &str = "request-merged-nothing-published";
155/// The code for a published version whose release page has no artifact.
156pub const PUBLISHED_NO_ARTIFACTS: &str = "published-no-artifacts";
157/// The code for an artifact no attestation covers.
158pub const ARTIFACT_WITHOUT_ATTESTATION: &str = "artifact-without-attestation";
159
160/// The recovery section for a merged request that published nothing.
161const RECOVERY_NOTHING_PUBLISHED: &str = "The release request merged and nothing published";
162/// The recovery section for an artifact build that left nothing, or
163/// nothing attested.
164const RECOVERY_ARTIFACT_BUILD: &str = "The artifact build failed";
165
166/// What the landed pair releases through, from the record.
167#[derive(Debug, Clone)]
168pub struct Pair {
169    /// The release driver.
170    pub driver: String,
171    /// The forge.
172    pub forge: Forge,
173    /// The project path on the forge.
174    pub repo: String,
175    /// The trunk branch.
176    pub trunk: String,
177    /// Whether the landing builds artifacts the release page carries.
178    pub artifacts: bool,
179    /// The package name the registry knows, where the pair has one.
180    pub package: Option<String>,
181}
182
183impl Pair {
184    /// The head branch prefix the driver's release request carries.
185    #[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    /// The workflow file that tags and publishes, on GitHub.
196    #[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    /// The workflow file that builds the artifacts, on GitHub, where the
207    /// landing builds any.
208    #[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    /// The registry the driver publishes to, where it has one.
220    #[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/// A registry with a public read-back.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum Registry {
233    /// crates.io.
234    CratesIo,
235    /// The Python Package Index.
236    Pypi,
237}
238
239impl Registry {
240    /// The public URL that answers 200 for a published version and 404
241    /// for an unpublished one; with no version, for the package itself.
242    #[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/// The version a release request's title proposes: its last
258/// whitespace-separated token that reads as a version, with any leading
259/// `v` dropped.
260#[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/// Whether `text` reads as `<major>.<minor>.<patch>`, with an optional
272/// pre-release suffix.
273#[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/// The newest release among `tags`: a `v<major>.<minor>.<patch>` tag with
284/// no pre-release suffix, by version precedence.
285#[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, &current[1..])) {
296            newest = Some(tag);
297        }
298    }
299    newest.map(str::to_owned)
300}
301
302/// Where a list of GitHub check runs stands.
303#[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/// Where a GitLab pipeline status stands.
337#[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/// Whether a run or a pipeline finished, and how.
347#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
348pub struct Run {
349    /// The forge's id for it.
350    pub id: u64,
351    /// `queued`, `in_progress`, or `completed` on GitHub; the pipeline
352    /// status on GitLab.
353    pub status: String,
354    /// How it concluded, once it did.
355    pub conclusion: Option<String>,
356    /// The branch or tag it ran on.
357    pub head_branch: Option<String>,
358}
359
360impl Run {
361    /// Whether it finished.
362    #[must_use]
363    pub const fn concluded(&self) -> bool {
364        self.conclusion.is_some()
365    }
366
367    /// Whether it finished with success.
368    #[must_use]
369    pub fn succeeded(&self) -> bool {
370        self.conclusion.as_deref() == Some("success")
371    }
372}
373
374/// The run of `workflow` on commit `sha`, selected by the commit and the
375/// push event, never by recency: a workflow that also reports on requests
376/// makes the newest run the wrong one.
377#[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/// The pipeline on commit `sha` that ran on `reference`.
402#[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/// What a release page carries.
425#[derive(Debug, Clone, Default, PartialEq, Eq)]
426pub struct Page {
427    /// Whether the page exists.
428    pub exists: bool,
429    /// Every artifact name on it.
430    pub assets: Vec<String>,
431    /// The artifacts no attestation covers.
432    pub unattested: Vec<String>,
433    /// Whether any attestation answer could not be read.
434    pub attestation_unknown: bool,
435}
436
437impl Page {
438    /// Whether the page carries its artifacts, each attested.
439    #[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/// The newest merged release request, and what followed it.
452#[derive(Debug, Clone)]
453pub struct Merged {
454    /// Its number.
455    pub number: u64,
456    /// The version its title proposed.
457    pub version: String,
458    /// Whether the tag for that version exists at the forge.
459    pub tagged: Answer,
460    /// Whether the version is published.
461    pub published: Answer,
462}
463
464/// Every symptom the observed state shows. Pure: the reads gather the
465/// facts, and this rule alone decides what they mean.
466///
467/// SATISFIES release-state:a-symptom-names-its-recovery
468///
469/// A merged request whose version has no tag, or no published version,
470/// published nothing. A published release whose pair builds artifacts and
471/// whose page carries none lost its artifact build. An artifact no
472/// attestation covers was uploaded outside the run that signs.
473#[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/// One read-only forge API answer.
522#[derive(Debug)]
523pub enum Api {
524    /// The call succeeded and parsed.
525    Ok(Value),
526    /// The forge answered 404.
527    Missing,
528    /// The call failed for another reason, with the CLI's own words.
529    Failed(String),
530}
531
532/// The forge CLI, resolved once.
533#[derive(Debug, Clone)]
534pub struct ForgeCli {
535    cli: PathBuf,
536    cwd: PathBuf,
537}
538
539impl ForgeCli {
540    /// A CLI already resolved, run from `cwd`.
541    #[must_use]
542    pub const fn new(cli: PathBuf, cwd: PathBuf) -> Self {
543        Self { cli, cwd }
544    }
545
546    /// The CLI for `forge`, run from `target`.
547    ///
548    /// # Errors
549    ///
550    /// The resolution's refusal where no usable binary resolves.
551    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    /// One read-only `api` call.
559    #[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        // `gh` renders "HTTP 404", `glab` "404 Not Found"; a bare
579        // substring would read an outage message mentioning 404 as one.
580        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/// Whether the registry serves `package`, at `version` where one is
596/// named: a 200 answers yes, a 404 answers no, and anything else is
597/// unknown.
598#[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/// The package name the registry knows, read from the target's own
625/// version file: `[package].name` in `Cargo.toml`, `[project].name` in
626/// `pyproject.toml`.
627#[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/// The GitLab project id a path names, URL-encoded once.
640#[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    /// The run is picked by its commit and its push event: a newer run on
711    /// a request, the same workflow, never answers for the commit.
712    #[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}