Skip to main content

release_kit/setup/
milestones.rs

1//! The publish milestones a setup check reads back: what the forge and
2//! the registry can show, and what only the operator can.
3//!
4//! Two milestones have a public read-back: the first version published,
5//! which the registry answers, and an automated release proven, which
6//! the forge answers by a successful publish run on the trunk and a
7//! release beside it. Every registry form no API exposes reads
8//! `operator`, with the binding's own read-back check, so a skill
9//! sequences the operator's steps without guessing where they stand.
10//! Milestones are information: none weighs on the check's verdict.
11//!
12//! SATISFIES setup-proof:the-check-reads-the-publish-milestones
13
14use serde_json::Value;
15
16use super::context::Ctx;
17use crate::detect::Forge;
18use crate::release_state::{self, Answer, Api, ForgeCli, Registry};
19
20/// One milestone, as the check reports it.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Milestone {
23    /// The stable milestone id.
24    pub id: &'static str,
25    /// `observed`, `not-observed`, `unknown`, `operator`, or
26    /// `not-applicable`.
27    pub status: &'static str,
28    /// What was read, or the read-back check that proves it.
29    pub detail: String,
30}
31
32impl Milestone {
33    /// The human line.
34    #[must_use]
35    pub fn line(&self) -> String {
36        format!("milestone {}: {} ({})", self.id, self.status, self.detail)
37    }
38}
39
40/// The milestone for the first version on the registry.
41pub const FIRST_VERSION: &str = "first-version-published";
42/// The milestone for one release through the automated path.
43pub const AUTOMATED_RELEASE: &str = "automated-release-proven";
44
45/// The status word for an answer the read gave.
46const fn word(answer: Answer) -> &'static str {
47    match answer {
48        Answer::Yes => "observed",
49        Answer::No => "not-observed",
50        Answer::Unknown => "unknown",
51        Answer::NotApplicable => "not-applicable",
52    }
53}
54
55/// The registry forms no API exposes, per driver, each with the
56/// binding's read-back check.
57fn operator_forms(driver: &str, package: &str) -> Vec<(&'static str, String)> {
58    match driver {
59        "rust" => vec![
60            (
61                "trusted-publisher",
62                format!(
63                    "rk binding rust, the trusted publisher: the Trusted Publishing table on crates.io/crates/{package}/settings lists the owner, the repository, and release-plz.yml"
64                ),
65            ),
66            (
67                "bootstrap-token-revoked",
68                "rk binding rust, revoking the bootstrap token: the token is gone from crates.io/settings/tokens, and the host's cargo credentials name no crates-io token".to_owned(),
69            ),
70            (
71                "trusted-publishing-required",
72                format!(
73                    "rk binding rust, the trusted publisher: on crates.io/crates/{package}/settings, require trusted publishing for all new versions reads as enabled after a reload"
74                ),
75            ),
76        ],
77        "python" => vec![
78            (
79                "trusted-publisher",
80                format!(
81                    "rk binding python, setup specifics: the publishing settings of {package} on PyPI list the owner, the repository, and release-please.yml"
82                ),
83            ),
84            (
85                "bootstrap-token-revoked",
86                "rk binding python, setup specifics: the project-scoped API token is gone from the PyPI account's API tokens".to_owned(),
87            ),
88        ],
89        _ => Vec::new(),
90    }
91}
92
93/// Read every milestone this target's pair has.
94#[must_use]
95pub fn observe(ctx: &Ctx) -> Vec<Milestone> {
96    let Some(driver) = ctx.driver().filter(|_| ctx.automatic_release()) else {
97        let reason = "the target selects no automated release".to_owned();
98        return vec![
99            Milestone {
100                id: FIRST_VERSION,
101                status: "not-applicable",
102                detail: reason.clone(),
103            },
104            Milestone {
105                id: AUTOMATED_RELEASE,
106                status: "not-applicable",
107                detail: reason,
108            },
109        ];
110    };
111    let registry = release_state::registry_for(driver, ctx.profile.release.registry);
112    let package = release_state::package_name(ctx.target.as_std_path(), driver);
113    let mut found = vec![first_version(registry, package.as_deref())];
114    found.push(automated_release(ctx, driver));
115    // The operator's registry forms exist only where a registry receives
116    // the release, so a crate that publishes to none reports no
117    // crates.io instruction.
118    if let Some(package) = package.as_deref().filter(|_| registry.is_some()) {
119        for (id, check) in operator_forms(driver, package) {
120            found.push(Milestone {
121                id,
122                status: "operator",
123                detail: check,
124            });
125        }
126    }
127    found
128}
129
130fn first_version(registry: Option<Registry>, package: Option<&str>) -> Milestone {
131    let (status, detail) = match (registry, package) {
132        (None, _) => (
133            "not-applicable",
134            "the release publishes to no registry; the release page is the distribution".to_owned(),
135        ),
136        (Some(_), None) => (
137            "unknown",
138            "the version file names no package the registry can be asked about".to_owned(),
139        ),
140        (Some(registry), Some(package)) => {
141            let answer = release_state::registry_serves(registry, package, None);
142            (
143                word(answer),
144                format!("{} answers for {package}", registry.url(package, None)),
145            )
146        }
147    };
148    Milestone {
149        id: FIRST_VERSION,
150        status,
151        detail,
152    }
153}
154
155fn automated_release(ctx: &Ctx, driver: &str) -> Milestone {
156    let milestone = |status, detail: String| Milestone {
157        id: AUTOMATED_RELEASE,
158        status,
159        detail,
160    };
161    let Some(forge) = ctx.forge else {
162        return milestone(
163            "not-applicable",
164            "the target names no forge this release reads".to_owned(),
165        );
166    };
167    if ctx.repo.is_empty() {
168        return milestone("unknown", "the target names no forge repository".to_owned());
169    }
170    let cli = ForgeCli::new(ctx.cli.clone(), ctx.target.as_std_path().to_path_buf());
171    let trunk = ctx.trunk();
172    let unknown =
173        |reason: String| milestone("unknown", format!("the forge did not answer: {reason}"));
174    // The newest release, the commit its tag names, and a successful
175    // publish run on the trunk at that commit: a release page alone can
176    // predate the automation, and a successful run can publish nothing.
177    let releases_path = match forge {
178        Forge::Github => format!("repos/{}/releases?per_page=1", ctx.repo),
179        Forge::Gitlab => format!(
180            "projects/{}/releases?per_page=1",
181            release_state::gitlab_project(&ctx.repo)
182        ),
183    };
184    let tag = match cli.api(&releases_path) {
185        Api::Ok(body) => body
186            .as_array()
187            .and_then(|list| list.first())
188            .and_then(|release| release["tag_name"].as_str())
189            .map(str::to_owned),
190        Api::Missing => None,
191        Api::Failed(reason) => return unknown(reason),
192    };
193    let Some(tag) = tag else {
194        return milestone("not-observed", "no release exists yet".to_owned());
195    };
196    let (commit_path, commit_pointer) = match forge {
197        Forge::Github => (format!("repos/{}/commits/{tag}", ctx.repo), "/sha"),
198        Forge::Gitlab => (
199            format!(
200                "projects/{}/repository/tags/{tag}",
201                release_state::gitlab_project(&ctx.repo)
202            ),
203            "/commit/id",
204        ),
205    };
206    let commit = match cli.api(&commit_path) {
207        Api::Ok(body) => body
208            .pointer(commit_pointer)
209            .and_then(Value::as_str)
210            .map(str::to_owned),
211        Api::Missing => None,
212        Api::Failed(reason) => return unknown(reason),
213    };
214    let Some(commit) = commit else {
215        return milestone(
216            "not-observed",
217            format!("the newest release, {tag}, names no commit the forge serves"),
218        );
219    };
220    let runs_path = match forge {
221        Forge::Github => {
222            let workflow = match driver {
223                "rust" => "release-plz.yml",
224                "python" => "release-please.yml",
225                _ => "release.yml",
226            };
227            format!(
228                "repos/{}/actions/workflows/{workflow}/runs?branch={trunk}&head_sha={commit}&event=push&status=success&per_page=1",
229                ctx.repo
230            )
231        }
232        Forge::Gitlab => format!(
233            "projects/{}/pipelines?ref={trunk}&sha={commit}&status=success&per_page=1",
234            release_state::gitlab_project(&ctx.repo)
235        ),
236    };
237    let ran = match cli.api(&runs_path) {
238        Api::Ok(body) => body["total_count"].as_u64().map_or_else(
239            || body.as_array().is_some_and(|list| !list.is_empty()),
240            |count| count > 0,
241        ),
242        Api::Missing => false,
243        Api::Failed(reason) => return unknown(reason),
244    };
245    if ran {
246        milestone(
247            "observed",
248            format!("a publish run succeeded on {trunk} at the commit {tag} names"),
249        )
250    } else {
251        milestone(
252            "not-observed",
253            format!("the newest release, {tag}, came from no successful publish run on {trunk}"),
254        )
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::{operator_forms, word};
261    use crate::release_state::Answer;
262
263    #[test]
264    fn each_registry_form_carries_its_read_back_check() {
265        let rust: Vec<&str> = operator_forms("rust", "widget")
266            .iter()
267            .map(|(id, _)| *id)
268            .collect();
269        assert_eq!(
270            rust,
271            [
272                "trusted-publisher",
273                "bootstrap-token-revoked",
274                "trusted-publishing-required"
275            ]
276        );
277        assert!(
278            operator_forms("rust", "widget")[0]
279                .1
280                .contains("crates.io/crates/widget/settings")
281        );
282        assert_eq!(operator_forms("python", "widget").len(), 2);
283        assert!(operator_forms("bash", "widget").is_empty());
284        assert_eq!(word(Answer::Yes), "observed");
285        assert_eq!(word(Answer::No), "not-observed");
286    }
287}