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 and forge, each with the
56/// binding's read-back check.
57fn operator_forms(
58    driver: &str,
59    package: &str,
60    forge: Option<Forge>,
61    repo: &str,
62) -> Vec<(&'static str, String)> {
63    match driver {
64        "rust" => vec![
65            (
66                "trusted-publisher",
67                format!(
68                    "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"
69                ),
70            ),
71            (
72                "bootstrap-token-revoked",
73                "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(),
74            ),
75            (
76                "trusted-publishing-required",
77                format!(
78                    "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"
79                ),
80            ),
81        ],
82        "python" => vec![
83            (
84                "trusted-publisher",
85                if forge == Some(Forge::Gitlab) {
86                    format!(
87                        "rk binding python, setup specifics: the publishing settings of {package} on PyPI list a GitLab publisher with the namespace and the project of {repo} and the top-level pipeline file .gitlab-ci.yml"
88                    )
89                } else {
90                    format!(
91                        "rk binding python, setup specifics: the publishing settings of {package} on PyPI list the owner, the repository, the workflow release.yml, and the environment pypi"
92                    )
93                },
94            ),
95            (
96                "bootstrap-token-revoked",
97                "rk binding python, setup specifics: the project-scoped API token is gone from the PyPI account's API tokens".to_owned(),
98            ),
99        ],
100        _ => Vec::new(),
101    }
102}
103
104/// Read every milestone this target's pair has.
105#[must_use]
106pub fn observe(ctx: &Ctx) -> Vec<Milestone> {
107    let Some(driver) = ctx.driver().filter(|_| ctx.automatic_release()) else {
108        let reason = "the target selects no automated release".to_owned();
109        return vec![
110            Milestone {
111                id: FIRST_VERSION,
112                status: "not-applicable",
113                detail: reason.clone(),
114            },
115            Milestone {
116                id: AUTOMATED_RELEASE,
117                status: "not-applicable",
118                detail: reason,
119            },
120        ];
121    };
122    let registry = release_state::registry_for(driver, ctx.profile.release.registry);
123    let package = crate::release_engine::adapter::package_name(ctx.target.as_std_path(), driver);
124    let mut found = vec![first_version(registry, package.as_deref())];
125    found.push(automated_release(ctx, driver));
126    // The operator's registry forms exist only where a registry receives
127    // the release, so a crate that publishes to none reports no
128    // crates.io instruction.
129    if let Some(package) = package.as_deref().filter(|_| registry.is_some()) {
130        for (id, check) in operator_forms(driver, package, ctx.forge, &ctx.repo) {
131            found.push(Milestone {
132                id,
133                status: "operator",
134                detail: check,
135            });
136        }
137    }
138    found
139}
140
141fn first_version(registry: Option<Registry>, package: Option<&str>) -> Milestone {
142    let (status, detail) = match (registry, package) {
143        (None, _) => (
144            "not-applicable",
145            "the release publishes to no registry; the release page is the distribution".to_owned(),
146        ),
147        (Some(_), None) => (
148            "unknown",
149            "the version file names no package the registry can be asked about".to_owned(),
150        ),
151        (Some(registry), Some(package)) => {
152            // A report: a registry that gives no answer leaves the
153            // milestone unknown, and its detail names why.
154            match release_state::registry_serves(registry, package, None) {
155                Ok(answer) => (
156                    word(answer),
157                    format!("{} answers for {package}", registry.url(package, None)),
158                ),
159                Err(reason) => ("unknown", reason),
160            }
161        }
162    };
163    Milestone {
164        id: FIRST_VERSION,
165        status,
166        detail,
167    }
168}
169
170fn automated_release(ctx: &Ctx, driver: &str) -> Milestone {
171    let milestone = |status, detail: String| Milestone {
172        id: AUTOMATED_RELEASE,
173        status,
174        detail,
175    };
176    let Some(forge) = ctx.forge else {
177        return milestone(
178            "not-applicable",
179            "the target names no forge this release reads".to_owned(),
180        );
181    };
182    if ctx.repo.is_empty() {
183        return milestone("unknown", "the target names no forge repository".to_owned());
184    }
185    let cli = ForgeCli::new(ctx.cli.clone(), ctx.target.as_std_path().to_path_buf());
186    let trunk = ctx.trunk();
187    let unknown =
188        |reason: String| milestone("unknown", format!("the forge did not answer: {reason}"));
189    // The newest release, the commit its tag names, and a successful
190    // publish run on the trunk at that commit: a release page alone can
191    // predate the automation, and a successful run can publish nothing.
192    let releases_path = match forge {
193        Forge::Github => format!("repos/{}/releases?per_page=1", ctx.repo),
194        Forge::Gitlab => format!(
195            "projects/{}/releases?per_page=1",
196            release_state::gitlab_project(&ctx.repo)
197        ),
198    };
199    let tag = match cli.api(&releases_path) {
200        Api::Ok(body) => body
201            .as_array()
202            .and_then(|list| list.first())
203            .and_then(|release| release["tag_name"].as_str())
204            .map(str::to_owned),
205        Api::Missing => None,
206        Api::Failed(reason) => return unknown(reason),
207    };
208    let Some(tag) = tag else {
209        return milestone("not-observed", "no release exists yet".to_owned());
210    };
211    let (commit_path, commit_pointer) = match forge {
212        Forge::Github => (format!("repos/{}/commits/{tag}", ctx.repo), "/sha"),
213        Forge::Gitlab => (
214            format!(
215                "projects/{}/repository/tags/{tag}",
216                release_state::gitlab_project(&ctx.repo)
217            ),
218            "/commit/id",
219        ),
220    };
221    let commit = match cli.api(&commit_path) {
222        Api::Ok(body) => body
223            .pointer(commit_pointer)
224            .and_then(Value::as_str)
225            .map(str::to_owned),
226        Api::Missing => None,
227        Api::Failed(reason) => return unknown(reason),
228    };
229    let Some(commit) = commit else {
230        return milestone(
231            "not-observed",
232            format!("the newest release, {tag}, names no commit the forge serves"),
233        );
234    };
235    let runs_path = match forge {
236        Forge::Github => {
237            let workflow = match driver {
238                "rust" => "release-plz.yml",
239                _ => "release.yml",
240            };
241            format!(
242                "repos/{}/actions/workflows/{workflow}/runs?branch={trunk}&head_sha={commit}&event=push&status=success&per_page=1",
243                ctx.repo
244            )
245        }
246        Forge::Gitlab => format!(
247            "projects/{}/pipelines?ref={trunk}&sha={commit}&status=success&per_page=1",
248            release_state::gitlab_project(&ctx.repo)
249        ),
250    };
251    let ran = match cli.api(&runs_path) {
252        Api::Ok(body) => body["total_count"].as_u64().map_or_else(
253            || body.as_array().is_some_and(|list| !list.is_empty()),
254            |count| count > 0,
255        ),
256        Api::Missing => false,
257        Api::Failed(reason) => return unknown(reason),
258    };
259    if ran {
260        milestone(
261            "observed",
262            format!("a publish run succeeded on {trunk} at the commit {tag} names"),
263        )
264    } else {
265        milestone(
266            "not-observed",
267            format!("the newest release, {tag}, came from no successful publish run on {trunk}"),
268        )
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::{operator_forms, word};
275    use crate::detect::Forge;
276    use crate::release_state::Answer;
277
278    #[test]
279    fn each_registry_form_carries_its_read_back_check() {
280        let rust: Vec<&str> = operator_forms("rust", "widget", Some(Forge::Github), "acme/widget")
281            .iter()
282            .map(|(id, _)| *id)
283            .collect();
284        assert_eq!(
285            rust,
286            [
287                "trusted-publisher",
288                "bootstrap-token-revoked",
289                "trusted-publishing-required"
290            ]
291        );
292        assert!(
293            operator_forms("rust", "widget", Some(Forge::Github), "acme/widget")[0]
294                .1
295                .contains("crates.io/crates/widget/settings")
296        );
297        let github = operator_forms("python", "widget", Some(Forge::Github), "acme/widget");
298        assert_eq!(github.len(), 2);
299        assert!(github[0].1.contains("release.yml"), "{}", github[0].1);
300        assert!(github[0].1.contains("environment pypi"), "{}", github[0].1);
301        let gitlab = operator_forms("python", "widget", Some(Forge::Gitlab), "acme/widget");
302        assert!(gitlab[0].1.contains("acme/widget"), "{}", gitlab[0].1);
303        assert!(gitlab[0].1.contains(".gitlab-ci.yml"), "{}", gitlab[0].1);
304        assert!(operator_forms("bash", "widget", Some(Forge::Github), "acme/widget").is_empty());
305        assert_eq!(word(Answer::Yes), "observed");
306        assert_eq!(word(Answer::No), "not-observed");
307    }
308}