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 = match driver {
112        "rust" => Some(Registry::CratesIo),
113        "python" => Some(Registry::Pypi),
114        _ => None,
115    };
116    let package = release_state::package_name(ctx.target.as_std_path(), driver);
117    let mut found = vec![first_version(registry, package.as_deref())];
118    found.push(automated_release(ctx, driver));
119    if let Some(package) = package.as_deref() {
120        for (id, check) in operator_forms(driver, package) {
121            found.push(Milestone {
122                id,
123                status: "operator",
124                detail: check,
125            });
126        }
127    }
128    found
129}
130
131fn first_version(registry: Option<Registry>, package: Option<&str>) -> Milestone {
132    let (status, detail) = match (registry, package) {
133        (None, _) => (
134            "not-applicable",
135            "the driver publishes to no registry; the release page is the distribution".to_owned(),
136        ),
137        (Some(_), None) => (
138            "unknown",
139            "the version file names no package the registry can be asked about".to_owned(),
140        ),
141        (Some(registry), Some(package)) => {
142            let answer = release_state::registry_serves(registry, package, None);
143            (
144                word(answer),
145                format!("{} answers for {package}", registry.url(package, None)),
146            )
147        }
148    };
149    Milestone {
150        id: FIRST_VERSION,
151        status,
152        detail,
153    }
154}
155
156fn automated_release(ctx: &Ctx, driver: &str) -> Milestone {
157    let milestone = |status, detail: String| Milestone {
158        id: AUTOMATED_RELEASE,
159        status,
160        detail,
161    };
162    let Some(forge) = ctx.forge else {
163        return milestone(
164            "not-applicable",
165            "the target names no forge this release reads".to_owned(),
166        );
167    };
168    if ctx.repo.is_empty() {
169        return milestone("unknown", "the target names no forge repository".to_owned());
170    }
171    let cli = ForgeCli::new(ctx.cli.clone(), ctx.target.as_std_path().to_path_buf());
172    let trunk = ctx.trunk();
173    let unknown =
174        |reason: String| milestone("unknown", format!("the forge did not answer: {reason}"));
175    // The newest release, the commit its tag names, and a successful
176    // publish run on the trunk at that commit: a release page alone can
177    // predate the automation, and a successful run can publish nothing.
178    let releases_path = match forge {
179        Forge::Github => format!("repos/{}/releases?per_page=1", ctx.repo),
180        Forge::Gitlab => format!(
181            "projects/{}/releases?per_page=1",
182            release_state::gitlab_project(&ctx.repo)
183        ),
184    };
185    let tag = match cli.api(&releases_path) {
186        Api::Ok(body) => body
187            .as_array()
188            .and_then(|list| list.first())
189            .and_then(|release| release["tag_name"].as_str())
190            .map(str::to_owned),
191        Api::Missing => None,
192        Api::Failed(reason) => return unknown(reason),
193    };
194    let Some(tag) = tag else {
195        return milestone("not-observed", "no release exists yet".to_owned());
196    };
197    let (commit_path, commit_pointer) = match forge {
198        Forge::Github => (format!("repos/{}/commits/{tag}", ctx.repo), "/sha"),
199        Forge::Gitlab => (
200            format!(
201                "projects/{}/repository/tags/{tag}",
202                release_state::gitlab_project(&ctx.repo)
203            ),
204            "/commit/id",
205        ),
206    };
207    let commit = match cli.api(&commit_path) {
208        Api::Ok(body) => body
209            .pointer(commit_pointer)
210            .and_then(Value::as_str)
211            .map(str::to_owned),
212        Api::Missing => None,
213        Api::Failed(reason) => return unknown(reason),
214    };
215    let Some(commit) = commit else {
216        return milestone(
217            "not-observed",
218            format!("the newest release, {tag}, names no commit the forge serves"),
219        );
220    };
221    let runs_path = match forge {
222        Forge::Github => {
223            let workflow = match driver {
224                "rust" => "release-plz.yml",
225                "python" => "release-please.yml",
226                _ => "release.yml",
227            };
228            format!(
229                "repos/{}/actions/workflows/{workflow}/runs?branch={trunk}&head_sha={commit}&event=push&status=success&per_page=1",
230                ctx.repo
231            )
232        }
233        Forge::Gitlab => format!(
234            "projects/{}/pipelines?ref={trunk}&sha={commit}&status=success&per_page=1",
235            release_state::gitlab_project(&ctx.repo)
236        ),
237    };
238    let ran = match cli.api(&runs_path) {
239        Api::Ok(body) => body["total_count"].as_u64().map_or_else(
240            || body.as_array().is_some_and(|list| !list.is_empty()),
241            |count| count > 0,
242        ),
243        Api::Missing => false,
244        Api::Failed(reason) => return unknown(reason),
245    };
246    if ran {
247        milestone(
248            "observed",
249            format!("a publish run succeeded on {trunk} at the commit {tag} names"),
250        )
251    } else {
252        milestone(
253            "not-observed",
254            format!("the newest release, {tag}, came from no successful publish run on {trunk}"),
255        )
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::{operator_forms, word};
262    use crate::release_state::Answer;
263
264    #[test]
265    fn each_registry_form_carries_its_read_back_check() {
266        let rust: Vec<&str> = operator_forms("rust", "widget")
267            .iter()
268            .map(|(id, _)| *id)
269            .collect();
270        assert_eq!(
271            rust,
272            [
273                "trusted-publisher",
274                "bootstrap-token-revoked",
275                "trusted-publishing-required"
276            ]
277        );
278        assert!(
279            operator_forms("rust", "widget")[0]
280                .1
281                .contains("crates.io/crates/widget/settings")
282        );
283        assert_eq!(operator_forms("python", "widget").len(), 2);
284        assert!(operator_forms("bash", "widget").is_empty());
285        assert_eq!(word(Answer::Yes), "observed");
286        assert_eq!(word(Answer::No), "not-observed");
287    }
288}