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::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    /// The prefix every release-line branch carries.
195    pub line_prefix: String,
196    /// Whether the landing builds artifacts the release page carries.
197    pub artifacts: bool,
198    /// The package name the registry knows, where the pair has one.
199    pub package: Option<String>,
200    /// The recorded `profile.release.registry` answer, absent where the
201    /// target never answered it.
202    pub registry_answer: Option<crate::profile::RegistryAnswer>,
203}
204
205impl Pair {
206    /// The head branch prefix the driver's release request carries.
207    #[must_use]
208    pub fn request_prefix(&self) -> Option<&'static str> {
209        match self.driver.as_str() {
210            "rust" => Some("release-plz-"),
211            // The engine keeps one request per base on `rk-release/<base>`.
212            "python" | "bash" => Some(crate::release_engine::REQUEST_PREFIX),
213            _ => None,
214        }
215    }
216
217    /// The workflow file that tags and publishes, on GitHub.
218    #[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    /// The workflow file that builds the artifacts, on GitHub, where the
228    /// landing builds any.
229    #[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    /// Whether `branch` is a release base: the trunk, or a release line.
241    #[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    /// The registry the driver publishes to, where it has one.
247    #[must_use]
248    pub fn registry(&self) -> Option<Registry> {
249        registry_for(&self.driver, self.registry_answer)
250    }
251}
252
253/// The registry a release publishes to, from its driver and its answer.
254///
255/// The one owner of the mapping from a driver and the recorded
256/// `profile.release.registry` answer, so the release status and the setup
257/// milestones read one registry. A rust release reads crates.io and a
258/// python release reads `PyPI`, each unless it answered `none`; every
259/// other driver publishes to no registry.
260#[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/// A registry with a public read-back.
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub enum Registry {
276    /// crates.io.
277    CratesIo,
278    /// The Python Package Index.
279    Pypi,
280}
281
282impl Registry {
283    /// The public URL that answers 200 for a published version and 404
284    /// for an unpublished one; with no version, for the package itself.
285    #[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/// The version a release request's title proposes: its last
301/// whitespace-separated token that reads as a version, with any leading
302/// `v` dropped.
303#[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/// Whether `text` reads as `<major>.<minor>.<patch>`, with an optional
315/// pre-release suffix.
316#[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/// The newest release among `tags`: a `v<major>.<minor>.<patch>` tag with
327/// no pre-release suffix, by version precedence.
328#[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, &current[1..])) {
339            newest = Some(tag);
340        }
341    }
342    newest.map(str::to_owned)
343}
344
345/// Where a list of GitHub check runs stands.
346#[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/// Where a GitLab pipeline status stands.
380#[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/// Whether a run or a pipeline finished, and how.
390#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
391pub struct Run {
392    /// The forge's id for it.
393    pub id: u64,
394    /// `queued`, `in_progress`, or `completed` on GitHub; the pipeline
395    /// status on GitLab.
396    pub status: String,
397    /// How it concluded, once it did.
398    pub conclusion: Option<String>,
399    /// The branch or tag it ran on.
400    pub head_branch: Option<String>,
401}
402
403impl Run {
404    /// Whether it finished.
405    #[must_use]
406    pub const fn concluded(&self) -> bool {
407        self.conclusion.is_some()
408    }
409
410    /// Whether it finished with success.
411    #[must_use]
412    pub fn succeeded(&self) -> bool {
413        self.conclusion.as_deref() == Some("success")
414    }
415}
416
417/// The run of `workflow` on commit `sha`, selected by the commit and the
418/// push event, never by recency: a workflow that also reports on requests
419/// makes the newest run the wrong one.
420#[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/// The pipeline on commit `sha` that ran on `reference`.
445#[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/// The branch-push pipeline on commit `sha` whose branch `base` accepts.
453///
454/// It is selected by the commit and the push source, never by recency. A
455/// merge-request, schedule, or child pipeline on the same commit runs no
456/// release, and neither does the pipeline of the tag the release pushed,
457/// whose ref `base` never accepts.
458#[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
467/// The oldest pipeline `keep` accepts.
468fn 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
489/// The suffix of the provenance bundle a release page carries beside a
490/// distribution it signs.
491pub const BUNDLE_SUFFIX: &str = ".sigstore.json";
492
493/// Whether a file on a release page is support material and no artifact:
494/// the engine's checksum file, or a provenance bundle. Neither counts
495/// toward a page's artifacts, and no attestation covers either.
496#[must_use]
497pub fn is_support(name: &str) -> bool {
498    name == crate::release_engine::CHECKSUMS || name.ends_with(BUNDLE_SUFFIX)
499}
500
501/// What a release page carries.
502#[derive(Debug, Clone, Default, PartialEq, Eq)]
503pub struct Page {
504    /// Whether the page exists.
505    pub exists: bool,
506    /// Every artifact name on it, support files left out.
507    pub assets: Vec<String>,
508    /// The artifacts no attestation covers.
509    pub unattested: Vec<String>,
510    /// Whether any attestation answer could not be read.
511    pub attestation_unknown: bool,
512}
513
514impl Page {
515    /// Whether the page carries its artifacts, each attested.
516    #[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/// The newest merged release request, and what followed it.
529#[derive(Debug, Clone)]
530pub struct Merged {
531    /// Its number.
532    pub number: u64,
533    /// The version its title proposed.
534    pub version: String,
535    /// Whether the tag for that version exists at the forge.
536    pub tagged: Answer,
537    /// Whether the version is published.
538    pub published: Answer,
539}
540
541/// Every symptom the observed state shows. Pure: the reads gather the
542/// facts, and this rule alone decides what they mean.
543///
544/// SATISFIES release-state:a-symptom-names-its-recovery
545///
546/// A merged request whose version has no tag, or no published version,
547/// published nothing. Where the tag is absent and the registry holds the
548/// version, the evidence says so, because that closes the un-bump. A published release whose pair builds artifacts and
549/// whose page carries none lost its artifact build. An artifact no
550/// attestation covers was uploaded outside the run that signs.
551#[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            // The registry answers here, because an untagged version on a
562            // pair with no registry reads unpublished: the version is out,
563            // so the un-bump is closed and the rerun at the same commit
564            // stays the path.
565            (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
607/// SATISFIES release-state:a-symptom-names-its-recovery
608///
609/// Each release tag newer than the newest complete release of its own
610/// line, with no complete page itself. The trunk and each release line
611/// keep their own cutoff: a `v<line>.*` tag belongs to that line, and
612/// every other release tag to the trunk. Each group is walked newest
613/// first and stops at its first complete release, so a tag older than a
614/// complete release of its line drops out, and an accepted gap needs no
615/// record. `complete` answers whether a tag's page is complete; an
616/// unknown answer stops the walk of its line, because the unread page
617/// could be the complete release that retires every older gap.
618pub 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            // A complete page is the cutoff, and an unread page could be
654            // one, so either stops the walk of the line.
655            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/// SATISFIES release-state:a-symptom-names-its-recovery
671///
672/// Each release tag the clone holds that the forge holds at another
673/// commit, or does not hold at all, in tag order. `local` and `forge` pair
674/// each tag with the commit it names; a local tag that is no
675/// `v<version>`, a candidate included, is skipped. A tag only the forge
676/// holds is no symptom: an ordinary fetch brings it. The forge holds the
677/// true release tags, because a release tag is derived and never
678/// authored, so the evidence ends with the fetch that refreshes the
679/// clone: the single-tag form where the commits differ, and the prune
680/// form where the forge lacks the tag, because an explicit refspec cannot
681/// remove a tag the remote does not hold.
682#[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/// One read-only forge API answer.
715#[derive(Debug)]
716pub enum Api {
717    /// The call succeeded and parsed.
718    Ok(Value),
719    /// The forge answered 404.
720    Missing,
721    /// The call failed for another reason, with the CLI's own words.
722    Failed(String),
723}
724
725/// The forge CLI, resolved once.
726#[derive(Debug, Clone)]
727pub struct ForgeCli {
728    cli: PathBuf,
729    cwd: PathBuf,
730}
731
732impl ForgeCli {
733    /// A CLI already resolved, run from `cwd`.
734    #[must_use]
735    pub const fn new(cli: PathBuf, cwd: PathBuf) -> Self {
736        Self { cli, cwd }
737    }
738
739    /// The CLI for `forge`, run from `target`.
740    ///
741    /// # Errors
742    ///
743    /// The resolution's refusal where no usable binary resolves.
744    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    /// One read-only `api` call.
752    #[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        // `gh` renders "HTTP 404", `glab` "404 Not Found"; a bare
772        // substring would read an outage message mentioning 404 as one.
773        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/// Whether the registry serves `package`, at `version` where one is
789/// named: a 200 answers yes, a 404 answers no, and anything else is
790/// unknown.
791#[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
817/// Every file the registry serves for `package` at `version`, with its
818/// SHA-256.
819///
820/// `Ok(None)` where the registry answers 404 for the version, and the
821/// reason where it answers anything else. It reads through the curl
822/// launcher [`registry_serves`] reads through.
823///
824/// # Errors
825///
826/// The reason where the registry gave no definite answer.
827pub 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/// The GitLab project id a path names, URL-encoded once.
868#[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    /// The run is picked by its commit and its push event: a newer run on
939    /// a request, the same workflow, never answers for the commit.
940    #[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    /// With no tag, a version the registry already serves closes the
1025    /// un-bump, so the evidence says the registry holds it.
1026    #[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}