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 code for a release tag with no complete release page.
161pub const TAG_WITHOUT_RELEASE: &str = "tag-without-release";
162
163/// The recovery section for a tag whose release page is missing or
164/// incomplete.
165const RECOVERY_TAG_WITHOUT_RELEASE: &str = "The tag exists and the release does not";
166
167/// The code for a release tag the clone holds at another commit than the
168/// forge.
169pub const LOCAL_TAG_DIFFERS: &str = "local-tag-differs";
170/// The code for a release tag the clone holds and the forge does not.
171pub const LOCAL_TAG_NOT_ON_FORGE: &str = "local-tag-not-on-forge";
172
173/// The recovery section for a clone whose release tag the forge no longer
174/// holds at that commit.
175const RECOVERY_STALE_LOCAL_TAG: &str = "A clone holds a stale release tag";
176
177/// The recovery section for a merged request that published nothing.
178const RECOVERY_NOTHING_PUBLISHED: &str = "The release request merged and nothing published";
179/// The recovery section for an artifact build that left nothing, or
180/// nothing attested.
181const RECOVERY_ARTIFACT_BUILD: &str = "The artifact build failed";
182
183/// What the landed pair releases through, from the record.
184#[derive(Debug, Clone)]
185pub struct Pair {
186    /// The release driver.
187    pub driver: String,
188    /// The forge.
189    pub forge: Forge,
190    /// The project path on the forge.
191    pub repo: String,
192    /// The trunk branch.
193    pub trunk: String,
194    /// Whether the landing builds artifacts the release page carries.
195    pub artifacts: bool,
196    /// The package name the registry knows, where the pair has one.
197    pub package: Option<String>,
198    /// The recorded `profile.release.registry` answer, absent where the
199    /// target never answered it.
200    pub registry_answer: Option<crate::profile::RegistryAnswer>,
201}
202
203impl Pair {
204    /// The head branch prefix the driver's release request carries.
205    #[must_use]
206    pub fn request_prefix(&self) -> Option<&'static str> {
207        match self.driver.as_str() {
208            "rust" => Some("release-plz-"),
209            "python" => Some("release-please--"),
210            "bash" => Some("chore/release-v"),
211            _ => None,
212        }
213    }
214
215    /// The workflow file that tags and publishes, on GitHub.
216    #[must_use]
217    pub fn publish_workflow(&self) -> Option<&'static str> {
218        match (self.forge, self.driver.as_str()) {
219            (Forge::Github, "rust") => Some("release-plz.yml"),
220            (Forge::Github, "python") => Some("release-please.yml"),
221            (Forge::Github, "bash") => Some("release.yml"),
222            _ => None,
223        }
224    }
225
226    /// The workflow file that builds the artifacts, on GitHub, where the
227    /// landing builds any.
228    #[must_use]
229    pub fn artifact_workflow(&self) -> Option<&'static str> {
230        if !self.artifacts {
231            return None;
232        }
233        match (self.forge, self.driver.as_str()) {
234            (Forge::Github, "rust" | "bash") => Some("release.yml"),
235            _ => None,
236        }
237    }
238
239    /// The registry the driver publishes to, where it has one.
240    #[must_use]
241    pub fn registry(&self) -> Option<Registry> {
242        registry_for(&self.driver, self.registry_answer)
243    }
244}
245
246/// The registry a release publishes to, from its driver and its answer.
247///
248/// The one owner of the mapping from a driver and the recorded
249/// `profile.release.registry` answer, so the release status and the setup
250/// milestones read one registry. A rust release reads crates.io unless it
251/// answered `none`; a python release reads `PyPI`; every other driver
252/// publishes to no registry.
253#[must_use]
254pub fn registry_for(
255    driver: &str,
256    answer: Option<crate::profile::RegistryAnswer>,
257) -> Option<Registry> {
258    match (driver, answer) {
259        ("rust", Some(crate::profile::RegistryAnswer::None)) => None,
260        ("rust", _) => Some(Registry::CratesIo),
261        ("python", _) => Some(Registry::Pypi),
262        _ => None,
263    }
264}
265
266/// A registry with a public read-back.
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub enum Registry {
269    /// crates.io.
270    CratesIo,
271    /// The Python Package Index.
272    Pypi,
273}
274
275impl Registry {
276    /// The public URL that answers 200 for a published version and 404
277    /// for an unpublished one; with no version, for the package itself.
278    #[must_use]
279    pub fn url(self, package: &str, version: Option<&str>) -> String {
280        match (self, version) {
281            (Self::CratesIo, Some(version)) => {
282                format!("https://crates.io/api/v1/crates/{package}/{version}")
283            }
284            (Self::CratesIo, None) => format!("https://crates.io/api/v1/crates/{package}"),
285            (Self::Pypi, Some(version)) => {
286                format!("https://pypi.org/pypi/{package}/{version}/json")
287            }
288            (Self::Pypi, None) => format!("https://pypi.org/pypi/{package}/json"),
289        }
290    }
291}
292
293/// The version a release request's title proposes: its last
294/// whitespace-separated token that reads as a version, with any leading
295/// `v` dropped.
296#[must_use]
297pub fn proposed_version(title: &str) -> Option<String> {
298    title
299        .split_whitespace()
300        .rev()
301        .map(|token| token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '.'))
302        .map(|token| token.strip_prefix('v').unwrap_or(token))
303        .find(|token| is_version(token))
304        .map(str::to_owned)
305}
306
307/// Whether `text` reads as `<major>.<minor>.<patch>`, with an optional
308/// pre-release suffix.
309#[must_use]
310pub fn is_version(text: &str) -> bool {
311    let core = text.split_once('-').map_or(text, |(core, _)| core);
312    let parts: Vec<&str> = core.split('.').collect();
313    parts.len() == 3
314        && parts
315            .iter()
316            .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
317}
318
319/// The newest release among `tags`: a `v<major>.<minor>.<patch>` tag with
320/// no pre-release suffix, by version precedence.
321#[must_use]
322pub fn newest_release<'a>(tags: impl IntoIterator<Item = &'a str>) -> Option<String> {
323    let mut newest: Option<&str> = None;
324    for tag in tags {
325        let Some(version) = tag.strip_prefix('v') else {
326            continue;
327        };
328        if !is_version(version) || version.contains('-') {
329            continue;
330        }
331        if newest.is_none_or(|current| version_is_newer(version, &current[1..])) {
332            newest = Some(tag);
333        }
334    }
335    newest.map(str::to_owned)
336}
337
338/// Where a list of GitHub check runs stands.
339#[must_use]
340pub fn github_checks(body: &Value) -> Checks {
341    let Some(runs) = body["check_runs"].as_array() else {
342        return Checks::Unknown;
343    };
344    if runs.is_empty() {
345        return Checks::Pending;
346    }
347    let failed = runs.iter().any(|run| {
348        matches!(
349            run["conclusion"].as_str(),
350            Some(
351                "failure"
352                    | "cancelled"
353                    | "timed_out"
354                    | "action_required"
355                    | "startup_failure"
356                    | "stale"
357            )
358        )
359    });
360    if failed {
361        return Checks::Failing;
362    }
363    if runs
364        .iter()
365        .any(|run| run["status"].as_str() != Some("completed"))
366    {
367        return Checks::Pending;
368    }
369    Checks::Passing
370}
371
372/// Where a GitLab pipeline status stands.
373#[must_use]
374pub fn gitlab_checks(status: Option<&str>) -> Checks {
375    match status {
376        Some("success") => Checks::Passing,
377        Some("failed" | "canceled" | "canceling") => Checks::Failing,
378        Some(_) | None => Checks::Pending,
379    }
380}
381
382/// Whether a run or a pipeline finished, and how.
383#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
384pub struct Run {
385    /// The forge's id for it.
386    pub id: u64,
387    /// `queued`, `in_progress`, or `completed` on GitHub; the pipeline
388    /// status on GitLab.
389    pub status: String,
390    /// How it concluded, once it did.
391    pub conclusion: Option<String>,
392    /// The branch or tag it ran on.
393    pub head_branch: Option<String>,
394}
395
396impl Run {
397    /// Whether it finished.
398    #[must_use]
399    pub const fn concluded(&self) -> bool {
400        self.conclusion.is_some()
401    }
402
403    /// Whether it finished with success.
404    #[must_use]
405    pub fn succeeded(&self) -> bool {
406        self.conclusion.as_deref() == Some("success")
407    }
408}
409
410/// The run of `workflow` on commit `sha`, selected by the commit and the
411/// push event, never by recency: a workflow that also reports on requests
412/// makes the newest run the wrong one.
413#[must_use]
414pub fn github_run_on(body: &Value, workflow: &str, sha: &str) -> Option<Run> {
415    let suffix = format!("/{workflow}");
416    body["workflow_runs"]
417        .as_array()?
418        .iter()
419        .filter(|run| run["head_sha"].as_str() == Some(sha))
420        .filter(|run| run["event"].as_str() == Some("push"))
421        .filter(|run| {
422            run["path"]
423                .as_str()
424                .is_some_and(|path| path.ends_with(&suffix) || path == workflow)
425        })
426        .map(|run| Run {
427            id: run["id"].as_u64().unwrap_or_default(),
428            status: run["status"].as_str().unwrap_or("unknown").to_owned(),
429            conclusion: (run["status"].as_str() == Some("completed"))
430                .then(|| run["conclusion"].as_str().map(str::to_owned))
431                .flatten(),
432            head_branch: run["head_branch"].as_str().map(str::to_owned),
433        })
434        .min_by_key(|run| run.id)
435}
436
437/// The pipeline on commit `sha` that ran on `reference`.
438#[must_use]
439pub fn gitlab_pipeline_on(body: &Value, reference: impl Fn(&str) -> bool) -> Option<Run> {
440    body.as_array()?
441        .iter()
442        .filter(|pipeline| pipeline["ref"].as_str().is_some_and(&reference))
443        .map(|pipeline| {
444            let status = pipeline["status"].as_str().unwrap_or("unknown").to_owned();
445            let conclusion = matches!(
446                status.as_str(),
447                "success" | "failed" | "canceled" | "skipped"
448            )
449            .then(|| status.clone());
450            Run {
451                id: pipeline["id"].as_u64().unwrap_or_default(),
452                status,
453                conclusion,
454                head_branch: pipeline["ref"].as_str().map(str::to_owned),
455            }
456        })
457        .min_by_key(|run| run.id)
458}
459
460/// What a release page carries.
461#[derive(Debug, Clone, Default, PartialEq, Eq)]
462pub struct Page {
463    /// Whether the page exists.
464    pub exists: bool,
465    /// Every artifact name on it.
466    pub assets: Vec<String>,
467    /// The artifacts no attestation covers.
468    pub unattested: Vec<String>,
469    /// Whether any attestation answer could not be read.
470    pub attestation_unknown: bool,
471}
472
473impl Page {
474    /// Whether the page carries its artifacts, each attested.
475    #[must_use]
476    pub const fn verified(&self) -> Answer {
477        if !self.exists || self.assets.is_empty() || !self.unattested.is_empty() {
478            return Answer::No;
479        }
480        if self.attestation_unknown {
481            return Answer::Unknown;
482        }
483        Answer::Yes
484    }
485}
486
487/// The newest merged release request, and what followed it.
488#[derive(Debug, Clone)]
489pub struct Merged {
490    /// Its number.
491    pub number: u64,
492    /// The version its title proposed.
493    pub version: String,
494    /// Whether the tag for that version exists at the forge.
495    pub tagged: Answer,
496    /// Whether the version is published.
497    pub published: Answer,
498}
499
500/// Every symptom the observed state shows. Pure: the reads gather the
501/// facts, and this rule alone decides what they mean.
502///
503/// SATISFIES release-state:a-symptom-names-its-recovery
504///
505/// A merged request whose version has no tag, or no published version,
506/// published nothing. A published release whose pair builds artifacts and
507/// whose page carries none lost its artifact build. An artifact no
508/// attestation covers was uploaded outside the run that signs.
509#[must_use]
510pub fn symptoms(
511    merged: Option<&Merged>,
512    last: &LastRelease,
513    page: Option<&Page>,
514    artifacts: bool,
515) -> Vec<Symptom> {
516    let mut found = Vec::new();
517    if let Some(merged) = merged {
518        let missing = match (merged.tagged, merged.published) {
519            (Answer::No, _) => Some(format!("no tag v{} exists", merged.version)),
520            (_, Answer::No) => Some(format!("{} is not published", merged.version)),
521            _ => None,
522        };
523        if let Some(missing) = missing {
524            found.push(Symptom {
525                code: MERGED_NOTHING_PUBLISHED,
526                evidence: format!(
527                    "release request #{} proposing {} merged, and {missing}",
528                    merged.number, merged.version
529                ),
530                recovery: RECOVERY_NOTHING_PUBLISHED,
531            });
532        }
533    }
534    if let (true, Some(tag), Answer::Yes, Some(page)) =
535        (artifacts, last.tag.as_deref(), last.published, page)
536    {
537        if page.assets.is_empty() {
538            found.push(Symptom {
539                code: PUBLISHED_NO_ARTIFACTS,
540                evidence: format!("{tag} is published and its release page carries no artifact"),
541                recovery: RECOVERY_ARTIFACT_BUILD,
542            });
543        } else if !page.unattested.is_empty() {
544            found.push(Symptom {
545                code: ARTIFACT_WITHOUT_ATTESTATION,
546                evidence: format!(
547                    "{tag} carries {} with no attestation",
548                    page.unattested.join(", ")
549                ),
550                recovery: RECOVERY_ARTIFACT_BUILD,
551            });
552        }
553    }
554    found
555}
556
557/// SATISFIES release-state:a-symptom-names-its-recovery
558///
559/// Each release tag newer than the newest complete release of its own
560/// line, with no complete page itself. The trunk and each release line
561/// keep their own cutoff: a `v<line>.*` tag belongs to that line, and
562/// every other release tag to the trunk. Each group is walked newest
563/// first and stops at its first complete release, so a tag older than a
564/// complete release of its line drops out, and an accepted gap needs no
565/// record. `complete` answers whether a tag's page is complete; an
566/// unknown answer stops the walk of its line, because the unread page
567/// could be the complete release that retires every older gap.
568pub fn orphan_tags(
569    tags: &[&str],
570    lines: &[&str],
571    mut complete: impl FnMut(&str) -> Answer,
572) -> Vec<Symptom> {
573    let line_of = |version: &str| {
574        lines
575            .iter()
576            .find(|line| version.starts_with(&format!("{line}.")))
577            .copied()
578    };
579    let mut groups: std::collections::BTreeMap<Option<&str>, Vec<&str>> =
580        std::collections::BTreeMap::new();
581    for tag in tags {
582        let Some(version) = tag.strip_prefix('v') else {
583            continue;
584        };
585        if !is_version(version) || version.contains('-') {
586            continue;
587        }
588        groups.entry(line_of(version)).or_default().push(tag);
589    }
590    let mut found = Vec::new();
591    for (line, mut group) in groups {
592        group.sort_by(|left, right| {
593            if version_is_newer(&left[1..], &right[1..]) {
594                std::cmp::Ordering::Less
595            } else if version_is_newer(&right[1..], &left[1..]) {
596                std::cmp::Ordering::Greater
597            } else {
598                std::cmp::Ordering::Equal
599            }
600        });
601        let place = line.map_or_else(|| "the trunk".to_owned(), |line| format!("the line {line}"));
602        for tag in group {
603            // A complete page is the cutoff, and an unread page could be
604            // one, so either stops the walk of the line.
605            if complete(tag) != Answer::No {
606                break;
607            }
608            found.push(Symptom {
609                code: TAG_WITHOUT_RELEASE,
610                evidence: format!(
611                    "{tag} exists on {place} with no complete release page, and no complete release of that line is newer"
612                ),
613                recovery: RECOVERY_TAG_WITHOUT_RELEASE,
614            });
615        }
616    }
617    found
618}
619
620/// SATISFIES release-state:a-symptom-names-its-recovery
621///
622/// Each release tag the clone holds that the forge holds at another
623/// commit, or does not hold at all, in tag order. `local` and `forge` pair
624/// each tag with the commit it names; a local tag that is no
625/// `v<version>`, a candidate included, is skipped. A tag only the forge
626/// holds is no symptom: an ordinary fetch brings it. The forge holds the
627/// true release tags, because a release tag is derived and never
628/// authored, so the evidence ends with the fetch that refreshes the
629/// clone: the single-tag form where the commits differ, and the prune
630/// form where the forge lacks the tag, because an explicit refspec cannot
631/// remove a tag the remote does not hold.
632#[must_use]
633pub fn local_tag_drift(local: &[(&str, &str)], forge: &[(&str, &str)]) -> Vec<Symptom> {
634    let mut local: Vec<(&str, &str)> = local
635        .iter()
636        .filter(|(tag, _)| tag.strip_prefix('v').is_some_and(is_version))
637        .copied()
638        .collect();
639    local.sort_unstable();
640    local
641        .into_iter()
642        .filter_map(|(tag, commit)| {
643            match forge.iter().find(|(name, _)| *name == tag) {
644                Some((_, held)) if *held == commit => None,
645                Some((_, held)) => Some(Symptom {
646                    code: LOCAL_TAG_DIFFERS,
647                    evidence: format!(
648                        "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}'"
649                    ),
650                    recovery: RECOVERY_STALE_LOCAL_TAG,
651                }),
652                None => Some(Symptom {
653                    code: LOCAL_TAG_NOT_ON_FORGE,
654                    evidence: format!(
655                        "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*'"
656                    ),
657                    recovery: RECOVERY_STALE_LOCAL_TAG,
658                }),
659            }
660        })
661        .collect()
662}
663
664/// One read-only forge API answer.
665#[derive(Debug)]
666pub enum Api {
667    /// The call succeeded and parsed.
668    Ok(Value),
669    /// The forge answered 404.
670    Missing,
671    /// The call failed for another reason, with the CLI's own words.
672    Failed(String),
673}
674
675/// The forge CLI, resolved once.
676#[derive(Debug, Clone)]
677pub struct ForgeCli {
678    cli: PathBuf,
679    cwd: PathBuf,
680}
681
682impl ForgeCli {
683    /// A CLI already resolved, run from `cwd`.
684    #[must_use]
685    pub const fn new(cli: PathBuf, cwd: PathBuf) -> Self {
686        Self { cli, cwd }
687    }
688
689    /// The CLI for `forge`, run from `target`.
690    ///
691    /// # Errors
692    ///
693    /// The resolution's refusal where no usable binary resolves.
694    pub fn resolve(forge: Forge, target: &Utf8Path) -> Result<Self, crate::error::RkError> {
695        Ok(Self {
696            cli: crate::setup::context::resolve_cli(forge)?,
697            cwd: target.as_std_path().to_path_buf(),
698        })
699    }
700
701    /// One read-only `api` call.
702    #[must_use]
703    pub fn api(&self, path: &str) -> Api {
704        let answered = std::process::Command::new(&self.cli)
705            .args(["api", path])
706            .current_dir(&self.cwd)
707            .env("GH_PAGER", "")
708            .env("GLAB_PAGER", "")
709            .env("NO_COLOR", "1")
710            .output();
711        let output = match answered {
712            Ok(output) => output,
713            Err(source) => return Api::Failed(format!("the forge CLI did not run: {source}")),
714        };
715        if output.status.success() {
716            return serde_json::from_slice::<Value>(&output.stdout).map_or_else(
717                |_| Api::Failed("the forge answer did not parse as JSON".to_owned()),
718                Api::Ok,
719            );
720        }
721        // `gh` renders "HTTP 404", `glab` "404 Not Found"; a bare
722        // substring would read an outage message mentioning 404 as one.
723        let stderr = String::from_utf8_lossy(&output.stderr);
724        if stderr.contains("HTTP 404") || stderr.contains("404 Not Found") {
725            return Api::Missing;
726        }
727        Api::Failed(
728            stderr
729                .lines()
730                .rev()
731                .find(|line| !line.trim().is_empty())
732                .unwrap_or("no output")
733                .to_owned(),
734        )
735    }
736}
737
738/// Whether the registry serves `package`, at `version` where one is
739/// named: a 200 answers yes, a 404 answers no, and anything else is
740/// unknown.
741#[must_use]
742pub fn registry_serves(registry: Registry, package: &str, version: Option<&str>) -> Answer {
743    let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
744    let answered = std::process::Command::new(curl)
745        .args([
746            "-sS",
747            "-o",
748            "/dev/null",
749            "-w",
750            "%{http_code}",
751            "--max-time",
752            "20",
753        ])
754        .args(["-A", concat!("release-kit/", env!("CARGO_PKG_VERSION"))])
755        .arg(registry.url(package, version))
756        .output();
757    match answered {
758        Ok(output) => match String::from_utf8_lossy(&output.stdout).trim() {
759            "200" => Answer::Yes,
760            "404" => Answer::No,
761            _ => Answer::Unknown,
762        },
763        Err(_) => Answer::Unknown,
764    }
765}
766
767/// The package name the registry knows, read from the target's own
768/// version file: `[package].name` in `Cargo.toml`, `[project].name` in
769/// `pyproject.toml`.
770#[must_use]
771pub fn package_name(target: &Path, driver: &str) -> Option<String> {
772    let (file, table) = match driver {
773        "rust" => ("Cargo.toml", "package"),
774        "python" => ("pyproject.toml", "project"),
775        _ => return None,
776    };
777    let text = std::fs::read_to_string(target.join(file)).ok()?;
778    let value: toml::Table = toml::from_str(&text).ok()?;
779    value.get(table)?.get("name")?.as_str().map(str::to_owned)
780}
781
782/// The GitLab project id a path names, URL-encoded once.
783#[must_use]
784pub fn gitlab_project(repo: &str) -> String {
785    repo.replace('/', "%2F")
786}
787
788#[cfg(test)]
789mod tests {
790    use serde_json::json;
791
792    use super::{
793        Answer, Checks, LastRelease, Merged, Page, github_checks, github_run_on, gitlab_checks,
794        local_tag_drift, newest_release, proposed_version, symptoms,
795    };
796
797    #[test]
798    fn a_title_proposes_its_last_version_token() {
799        assert_eq!(
800            proposed_version("chore: release v0.8.7").as_deref(),
801            Some("0.8.7")
802        );
803        assert_eq!(
804            proposed_version("chore(main): release 1.2.3").as_deref(),
805            Some("1.2.3")
806        );
807        assert_eq!(
808            proposed_version("chore(release): v2.0.0-rc.1").as_deref(),
809            Some("2.0.0-rc.1")
810        );
811        assert_eq!(proposed_version("chore: release"), None);
812    }
813
814    #[test]
815    fn the_newest_release_skips_candidates_and_other_tags() {
816        assert_eq!(
817            newest_release(["v0.9.0", "v0.10.0", "v0.11.0-rc.1", "latest", "v1.2"]).as_deref(),
818            Some("v0.10.0")
819        );
820        assert_eq!(newest_release(["nightly"]), None);
821    }
822
823    #[test]
824    fn check_runs_judge_failing_before_pending() {
825        assert_eq!(github_checks(&json!({"check_runs": []})), Checks::Pending);
826        assert_eq!(
827            github_checks(&json!({"check_runs": [
828                {"status": "completed", "conclusion": "success"},
829                {"status": "in_progress", "conclusion": null}
830            ]})),
831            Checks::Pending
832        );
833        assert_eq!(
834            github_checks(&json!({"check_runs": [
835                {"status": "completed", "conclusion": "failure"},
836                {"status": "in_progress", "conclusion": null}
837            ]})),
838            Checks::Failing
839        );
840        assert_eq!(
841            github_checks(&json!({"check_runs": [
842                {"status": "completed", "conclusion": "success"},
843                {"status": "completed", "conclusion": "skipped"}
844            ]})),
845            Checks::Passing
846        );
847        assert_eq!(github_checks(&json!({})), Checks::Unknown);
848        assert_eq!(gitlab_checks(Some("running")), Checks::Pending);
849        assert_eq!(gitlab_checks(Some("failed")), Checks::Failing);
850        assert_eq!(gitlab_checks(Some("success")), Checks::Passing);
851    }
852
853    /// The run is picked by its commit and its push event: a newer run on
854    /// a request, the same workflow, never answers for the commit.
855    #[test]
856    fn a_run_is_selected_by_its_commit_never_by_recency() {
857        let body = json!({"workflow_runs": [
858            {"id": 30, "head_sha": "bbb", "event": "pull_request", "status": "in_progress",
859             "conclusion": null, "path": ".github/workflows/release-plz.yml", "head_branch": "feat/x"},
860            {"id": 20, "head_sha": "aaa", "event": "push", "status": "completed",
861             "conclusion": "success", "path": ".github/workflows/release-plz.yml", "head_branch": "master"},
862            {"id": 21, "head_sha": "aaa", "event": "push", "status": "queued",
863             "conclusion": null, "path": ".github/workflows/release.yml", "head_branch": "v1.0.0"}
864        ]});
865        let publish = github_run_on(&body, "release-plz.yml", "aaa").expect("the publish run");
866        assert_eq!(publish.id, 20);
867        assert!(publish.succeeded());
868        let artifact = github_run_on(&body, "release.yml", "aaa").expect("the artifact run");
869        assert_eq!(artifact.id, 21);
870        assert!(!artifact.concluded());
871        assert_eq!(github_run_on(&body, "release-plz.yml", "bbb"), None);
872    }
873
874    fn last(tag: &str, published: Answer) -> LastRelease {
875        LastRelease {
876            tag: Some(tag.to_owned()),
877            published,
878            artifacts_verified: Answer::Unknown,
879        }
880    }
881
882    #[test]
883    fn each_symptom_has_its_rule() {
884        let merged = Merged {
885            number: 12,
886            version: "1.2.3".into(),
887            tagged: Answer::No,
888            published: Answer::Unknown,
889        };
890        let found = symptoms(Some(&merged), &last("v1.2.2", Answer::Yes), None, false);
891        assert_eq!(found.len(), 1);
892        assert_eq!(found[0].code, super::MERGED_NOTHING_PUBLISHED);
893        assert_eq!(
894            found[0].recovery,
895            "The release request merged and nothing published"
896        );
897        let unpublished = Merged {
898            tagged: Answer::Yes,
899            published: Answer::No,
900            ..merged.clone()
901        };
902        assert_eq!(
903            symptoms(Some(&unpublished), &last("v1.2.3", Answer::No), None, false)[0].code,
904            super::MERGED_NOTHING_PUBLISHED
905        );
906        let shipped = Merged {
907            tagged: Answer::Yes,
908            published: Answer::Yes,
909            ..merged
910        };
911        let empty = Page {
912            exists: true,
913            ..Page::default()
914        };
915        let found = symptoms(
916            Some(&shipped),
917            &last("v1.2.3", Answer::Yes),
918            Some(&empty),
919            true,
920        );
921        assert_eq!(found.len(), 1);
922        assert_eq!(found[0].code, super::PUBLISHED_NO_ARTIFACTS);
923        assert_eq!(found[0].recovery, "The artifact build failed");
924        let unattested = Page {
925            exists: true,
926            assets: vec!["widget.tar.gz".into()],
927            unattested: vec!["widget.tar.gz".into()],
928            attestation_unknown: false,
929        };
930        let found = symptoms(None, &last("v1.2.3", Answer::Yes), Some(&unattested), true);
931        assert_eq!(found[0].code, super::ARTIFACT_WITHOUT_ATTESTATION);
932        assert!(found[0].evidence.contains("widget.tar.gz"));
933        assert!(
934            symptoms(None, &last("v1.2.3", Answer::Yes), Some(&empty), false).is_empty(),
935            "a pair that builds no artifact has no artifact symptom"
936        );
937    }
938
939    #[test]
940    fn local_tag_drift_reports_a_moved_and_a_local_only_tag() {
941        let found = local_tag_drift(
942            &[("v0.3.0", "ccc"), ("v0.1.0", "aaa"), ("v0.2.0", "bbb")],
943            &[("v0.1.0", "fff"), ("v0.3.0", "ccc"), ("v0.4.0", "ddd")],
944        );
945        let codes: Vec<&str> = found.iter().map(|symptom| symptom.code).collect();
946        assert_eq!(
947            codes,
948            [super::LOCAL_TAG_DIFFERS, super::LOCAL_TAG_NOT_ON_FORGE],
949            "the moved tag and the local-only tag, in tag order: {found:?}"
950        );
951        assert!(found[0].evidence.contains("v0.1.0 at aaa"));
952        assert!(found[0].evidence.contains("holds it at fff"));
953        assert!(found[1].evidence.contains("v0.2.0 at bbb"));
954        assert!(
955            found
956                .iter()
957                .all(|symptom| symptom.recovery == "A clone holds a stale release tag")
958        );
959    }
960
961    #[test]
962    fn the_answer_words_are_the_wire_form() {
963        for (answer, word) in [
964            (Answer::Yes, "yes"),
965            (Answer::No, "no"),
966            (Answer::Unknown, "unknown"),
967            (Answer::NotApplicable, "not-applicable"),
968        ] {
969            assert_eq!(answer.as_str(), word);
970            assert_eq!(
971                serde_json::to_string(&answer).expect("serializes"),
972                format!("\"{word}\"")
973            );
974        }
975        assert_eq!(Answer::of(true), Answer::Yes);
976    }
977}