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